Skip to content

Commit 8c418bf

Browse files
committed
Separate QBO and IES bulk export groups
1 parent 8d76b71 commit 8c418bf

3 files changed

Lines changed: 102 additions & 18 deletions

File tree

src/hooks/useSearchBulkActions.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopo
77
import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext';
88
import type {BulkPaySelectionData, PaymentData, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types';
99

10-
import {getAccountingIntegrationDisplayName} from '@libs/AccountingUtils';
10+
import {getAccountingIntegrationDisplayName, getExportLabelForConnection} from '@libs/AccountingUtils';
1111
import {getExpensifyCardStatementPDF} from '@libs/actions/CompanyCards';
1212
import {exportReceiptsToZip, exportReportsToPDF} from '@libs/actions/Export';
1313
import {unholdRequest} from '@libs/actions/IOU/Hold';
@@ -1782,21 +1782,33 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
17821782
return reportExportOptions.includes(exportOption);
17831783
};
17841784

1785-
// Group the selected reports by their connected accounting integration. A single-workspace
1785+
// Group the selected reports by their connected accounting integration / product. A single-workspace
17861786
// selection collapses to one group (unchanged behavior), while a multi-workspace selection
17871787
// surfaces one export + one "Mark as exported" option per integration, each scoped to the
1788-
// reports that belong to it.
1789-
const reportsByIntegration = new Map<NonNullable<ReturnType<typeof getConnectedIntegration>>, typeof selectedReports>();
1788+
// reports that belong to it (e.g. QBO and IES workspaces form distinct groups).
1789+
const reportsByIntegration = new Map<
1790+
string,
1791+
{
1792+
integration: NonNullable<ReturnType<typeof getConnectedIntegration>>;
1793+
integrationPolicy: OnyxEntry<Policy>;
1794+
reports: typeof selectedReports;
1795+
}
1796+
>();
17901797
if (isReportsTab && selectedReportIDs.length > 0 && includeReportLevelExport) {
17911798
for (const report of selectedReports) {
17921799
const reportPolicy = report.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] : undefined;
17931800
const reportIntegration = getConnectedIntegration(reportPolicy);
17941801
if (!reportIntegration) {
17951802
continue;
17961803
}
1797-
const reportsForIntegration = reportsByIntegration.get(reportIntegration) ?? [];
1798-
reportsForIntegration.push(report);
1799-
reportsByIntegration.set(reportIntegration, reportsForIntegration);
1804+
const exportLabel = getExportLabelForConnection(reportIntegration, reportPolicy);
1805+
const group = reportsByIntegration.get(exportLabel) ?? {
1806+
integration: reportIntegration,
1807+
integrationPolicy: reportPolicy ?? policy,
1808+
reports: [],
1809+
};
1810+
group.reports.push(report);
1811+
reportsByIntegration.set(exportLabel, group);
18001812
}
18011813
}
18021814

@@ -1846,7 +1858,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
18461858
if (companyIDs.size > 1) {
18471859
showConfirmModal({
18481860
title: translate('workspace.exportDifferentCompaniesModal.title'),
1849-
prompt: translate('workspace.exportDifferentCompaniesModal.description', integration),
1861+
prompt: translate('workspace.exportDifferentCompaniesModal.description', integration, connectionNameFriendly),
18501862
confirmText: translate('workspace.exportDifferentCompaniesModal.confirmText'),
18511863
shouldShowCancelButton: false,
18521864
});
@@ -1918,7 +1930,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
19181930
}
19191931

19201932
showConfirmModal({
1921-
title: translate('workspace.exportPartialModal.title', integrationReportIDs.length, totalSelectedReportsCount, integration),
1933+
title: translate('workspace.exportPartialModal.title', integrationReportIDs.length, totalSelectedReportsCount, integration, connectionNameFriendly),
19221934
// Fixed subtitle describes the partial scope; the scrollable prompt lists the report names
19231935
// that will actually be exported for the chosen integration. A partial export can happen for
19241936
// two independent reasons: part of the selection belongs to other integrations, and/or some
@@ -1928,6 +1940,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
19281940
integration,
19291941
integrationGroupSize < totalSelectedReportsCount,
19301942
integrationReportIDs.length < integrationGroupSize,
1943+
connectionNameFriendly,
19311944
),
19321945
prompt: exportableReportNames.join('\n'),
19331946
confirmText: translate('workspace.exportPartialModal.confirmText', {count: integrationReportIDs.length}),
@@ -1943,9 +1956,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
19431956

19441957
// Group each integration's actions together, listing its "Export to <integration>" option
19451958
// immediately followed by its "Mark as exported" option, before moving on to the next integration.
1946-
for (const [integration, reportsForIntegration] of reportsByIntegration) {
1959+
for (const [, {integration, integrationPolicy, reports: reportsForIntegration}] of reportsByIntegration) {
19471960
const integrationGroupSize = reportsForIntegration.length;
1948-
const integrationPolicy = reportsForIntegration.at(0)?.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportsForIntegration.at(0)?.policyID}`] : policy;
19491961
const connectionNameFriendly = getAccountingIntegrationDisplayName(integrationPolicy, integration, translate);
19501962
const integrationIcon = getIntegrationIcon(integration, expensifyIcons);
19511963

src/languages/en.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7469,17 +7469,17 @@ const translations = {
74697469
},
74707470
exportDifferentCompaniesModal: {
74717471
title: 'Careful!',
7472-
description: (connectionName: ConnectionName) =>
7473-
`The selected reports are connected to different ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} companies, so they can't be exported together. Select reports connected to the same company and try again.`,
7472+
description: (connectionName: ConnectionName, connectionNameFriendly?: string) =>
7473+
`The selected reports are connected to different ${connectionNameFriendly ?? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} companies, so they can't be exported together. Select reports connected to the same company and try again.`,
74747474
confirmText: 'Got it',
74757475
},
74767476
exportPartialModal: {
7477-
title: (exportableCount: number, selectedCount: number, integration: ConnectionName) =>
7478-
`Export ${exportableCount}/${selectedCount} reports to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`,
7479-
description: (integration: ConnectionName, hasReportsOnOtherIntegrations: boolean, hasIneligibleReports: boolean) => {
7477+
title: (exportableCount: number, selectedCount: number, integration: ConnectionName, connectionNameFriendly?: string) =>
7478+
`Export ${exportableCount}/${selectedCount} reports to ${connectionNameFriendly ?? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`,
7479+
description: (integration: ConnectionName, hasReportsOnOtherIntegrations: boolean, hasIneligibleReports: boolean, connectionNameFriendly?: string) => {
74807480
const reasons: string[] = [];
74817481
if (hasReportsOnOtherIntegrations) {
7482-
reasons.push(`Only reports connected to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} will be exported.`);
7482+
reasons.push(`Only reports connected to ${connectionNameFriendly ?? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} will be exported.`);
74837483
}
74847484
if (hasIneligibleReports) {
74857485
reasons.push(`Only reports that are eligible to export will be exported.`);

tests/unit/hooks/useSearchBulkActionsExportTest.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,15 @@ jest.mock('@hooks/useLocalize', () => {
115115
return {
116116
__esModule: true,
117117
default: () => ({
118-
translate: (key: string) => (key === 'workspace.accounting.qbo' ? actualCONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline : key),
118+
translate: (key: string) => {
119+
if (key === 'workspace.accounting.qbo') {
120+
return actualCONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline;
121+
}
122+
if (key === 'workspace.accounting.intuitEnterpriseSuite') {
123+
return actualCONST.EXPORT_LABELS.INTUIT_ENTERPRISE_SUITE;
124+
}
125+
return key;
126+
},
119127
localeCompare: (a: string, b: string) => a && b,
120128
formatPhoneNumber: (phone: string) => phone,
121129
}),
@@ -272,6 +280,7 @@ const REPORT_ID_2 = 'report2';
272280
const POLICY_ID_2 = 'policy2';
273281
const NETSUITE_FRIENDLY_NAME = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[CONST.POLICY.CONNECTIONS.NAME.NETSUITE];
274282
const QBO_FRIENDLY_NAME = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[CONST.POLICY.CONNECTIONS.NAME.QBO];
283+
const IES_FRIENDLY_NAME = CONST.EXPORT_LABELS.INTUIT_ENTERPRISE_SUITE;
275284

276285
const expenseReportQueryJSON: SearchQueryJSON = {
277286
inputQuery: 'type:expense-report status:all',
@@ -572,6 +581,69 @@ describe('useSearchBulkActions - export options', () => {
572581
expect(markAsManuallyExported).not.toHaveBeenCalled();
573582
});
574583

584+
it('offers distinct QBO and IES export options and marks reports with their respective workspace product when both are selected', async () => {
585+
/**
586+
* Given: two selected reports across two workspaces:
587+
* - Workspace 1 connected to QuickBooks Online (QBO)
588+
* - Workspace 2 connected to Intuit Enterprise Suite (IES)
589+
*
590+
* When: the export bulk-action menu is built.
591+
*
592+
* Then: both "QuickBooks Online" and "Intuit Enterprise Suite" export options are present,
593+
* each followed by its own "Mark as exported" option scoped to that workspace.
594+
*/
595+
const policy1 = {
596+
id: POLICY_ID,
597+
connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}},
598+
};
599+
const policy2 = {
600+
id: POLICY_ID_2,
601+
connections: {
602+
[CONST.POLICY.CONNECTIONS.NAME.QBO]: {
603+
config: {credentials: {scope: 'app-foundations.custom-dimensions.read'}},
604+
},
605+
},
606+
};
607+
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy1);
608+
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, policy2);
609+
610+
mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)]);
611+
mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})];
612+
mockSelectedTransactions = {
613+
tx1: makeSelectedTransaction(),
614+
tx2: makeSelectedTransaction({reportID: REPORT_ID_2, policyID: POLICY_ID_2}),
615+
};
616+
617+
const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider});
618+
619+
await waitFor(() => {
620+
const subMenuItems = getExportSubMenuItems(result.current.headerButtonsOptions);
621+
expect(subMenuItems?.some((item) => item.text === IES_FRIENDLY_NAME)).toBe(true);
622+
});
623+
624+
const subMenuItems = getExportSubMenuItems(result.current.headerButtonsOptions) ?? [];
625+
626+
// Both QBO and IES export options are present
627+
expect(subMenuItems.some((item) => item.text === QBO_FRIENDLY_NAME)).toBe(true);
628+
expect(subMenuItems.some((item) => item.text === IES_FRIENDLY_NAME)).toBe(true);
629+
expect(subMenuItems.filter((item) => item.text === 'workspace.common.markAsExported')).toHaveLength(2);
630+
631+
const integrationOptionTexts = subMenuItems
632+
.map((item) => item.text)
633+
.filter((text) => text === QBO_FRIENDLY_NAME || text === IES_FRIENDLY_NAME || text === 'workspace.common.markAsExported');
634+
expect(integrationOptionTexts).toEqual([QBO_FRIENDLY_NAME, 'workspace.common.markAsExported', IES_FRIENDLY_NAME, 'workspace.common.markAsExported']);
635+
636+
// Selecting "Mark as exported" for IES calls markAsManuallyExported with the IES policy
637+
const markAsExportedIESOption = subMenuItems.find(
638+
(item) => item.text === 'workspace.common.markAsExported' && item.accessibilityLabel === `workspace.common.markAsExported, ${IES_FRIENDLY_NAME}`,
639+
);
640+
expect(markAsExportedIESOption).toBeDefined();
641+
642+
markAsExportedIESOption?.onSelected?.();
643+
644+
expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID_2], CONST.POLICY.CONNECTIONS.NAME.QBO, expect.objectContaining({id: POLICY_ID_2}));
645+
});
646+
575647
it('blocks the export and shows the different-companies modal when the selection spans one integration on different companyIDs', async () => {
576648
/**
577649
* Given: two approved reports on two workspaces both connected to NetSuite, but to DIFFERENT

0 commit comments

Comments
 (0)