Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/components/ConfirmContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -93,6 +96,9 @@ type ConfirmContentProps = {
/** Styles for prompt */
promptStyles?: StyleProp<TextStyle>;

/** Styles for subtitle */
subtitleStyles?: StyleProp<TextStyle>;

/** Styles for view */
contentStyles?: StyleProp<ViewStyle>;

Expand Down Expand Up @@ -131,6 +137,8 @@ function ConfirmContent({
confirmText = '',
cancelText = '',
prompt = '',
subtitle,
subtitleStyles,
success = true,
danger = false,
shouldDisableConfirmButtonWhenOffline = false,
Expand Down Expand Up @@ -168,6 +176,11 @@ function ConfirmContent({
const isCentered = shouldCenterContent;

const promptContent = typeof prompt === 'string' ? <Text style={[promptStyles, isCentered ? styles.textAlignCenter : {}]}>{prompt}</Text> : prompt;
// Rendered outside the (optionally scrollable) prompt so it stays fixed above the prompt.
let subtitleContent: ReactNode = subtitle;
if (typeof subtitle === 'string') {
subtitleContent = <Text style={[styles.mb4, subtitleStyles, isCentered ? styles.textAlignCenter : {}]}>{subtitle}</Text>;
}

return (
<>
Expand Down Expand Up @@ -226,6 +239,7 @@ function ConfirmContent({
/>
)}
</View>
{subtitleContent}
{shouldEnablePromptScroll ? <ScrollView style={styles.confirmModalPromptScrollable}>{promptContent}</ScrollView> : promptContent}
</View>

Expand Down
10 changes: 10 additions & 0 deletions src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -85,6 +88,9 @@ type ConfirmModalProps = {
/** Styles for prompt */
promptStyles?: StyleProp<TextStyle>;

/** Styles for subtitle */
subtitleStyles?: StyleProp<TextStyle>;

/** Styles for icon */
iconAdditionalStyles?: StyleProp<ViewStyle>;

Expand Down Expand Up @@ -144,6 +150,8 @@ function ConfirmModal({
confirmText = '',
cancelText = '',
prompt = '',
subtitle,
subtitleStyles,
success = true,
danger = false,
onCancel = () => {},
Expand Down Expand Up @@ -218,6 +226,8 @@ function ConfirmModal({
confirmText={confirmText}
cancelText={cancelText}
prompt={prompt}
subtitle={subtitle}
subtitleStyles={subtitleStyles}
success={success}
danger={danger}
isVisible={isVisible}
Expand Down
169 changes: 125 additions & 44 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1516,85 +1515,167 @@ 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<NonNullable<ReturnType<typeof getConnectedIntegration>>, 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 <integration>" 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<ReturnType<typeof getConnectedIntegration>>, 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,
}).then((result) => {
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 <integration>" 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,
additionalIconStyles: styles.integrationIcon,
});
}

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'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disambiguate per-integration mark actions

When selected reports span multiple integrations, this loop adds one Mark as exported item per integration, but each item uses the same visible/accessibility text and no description, so the entries differ only by icon. In a QBO + Sage/NetSuite selection, usersβ€”especially keyboard or screen-reader usersβ€”cannot tell which workspace integration will be marked, and choosing the wrong duplicate marks the wrong subset; include the integration name in the label or description.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot keep the option text as-is since it matches OldDot, and add an accessibilityLabel that includes the integration name to fix the screen reader issue. For example: Mark as exported, QuickBooks Online.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done β€” kept the visible option text as Mark as exported and added an accessibilityLabel that includes the integration name, so screen readers can distinguish the per-integration entries (e.g. Mark as exported, QuickBooks Online). The "Export to …" options already use the integration name as their visible text, so no change was needed there.

src/hooks/useSearchBulkActions.ts:1676

accessibilityLabel: `${translate('workspace.common.markAsExported')}, ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}`,

Pushed as dd5c36d. Local checks: npm run lint-changed βœ…, npm run typecheck-tsgo βœ…, npm test tests/unit/hooks/useSearchBulkActionsExportTest.ts βœ… (11 tests).

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,
Expand Down
21 changes: 21 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import type {
EditActionParams,
EmptyViolationSnapshotResultsSubtitleParams,
ExportAgainModalDescriptionParams,
ExportPartialModalTitleParams,
ExportPartialModalDescriptionParams,
ExportIntegrationSelectedParams,
IntacctMappingTitleParams,
InvalidPropertyParams,
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading