@@ -444,6 +485,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
operations={systemError?.operations}
raw={systemError?.raw}
onClose={() => setSystemError(null)}
+ labels={labels?.systemError}
data-testid="ImportSystemErrorDialog__38a6c3" />
>
);
diff --git a/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx b/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx
index 5111ac67a..c09c0f4a8 100644
--- a/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx
+++ b/packages/app-shell-core/src/components/import/ImportReviewQueue.jsx
@@ -7,6 +7,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover.jsx';
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../ui/command.jsx';
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from '../ui/dialog.jsx';
import { ScrollPane } from '../ui/scroll-pane.jsx';
+import { csvField } from '../../lib/csv/csvSerializer.js';
const DEFAULT_LABELS = {
filterAll: 'All',
@@ -79,11 +80,6 @@ const STATUS_TAG_COLORS = {
neutral: 'bg-[#F5F7F9] text-[#3F3F50]',
};
-function csvEscape(value) {
- const s = String(value ?? '');
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
-}
-
/** Line number + status pill, shared by every row in the frozen leading column. */
function StatusLineTag({ index, tag, children }) {
return (
@@ -266,12 +262,12 @@ function FkMismatchCell({ index, field, value, error, onSelect, simSearchFn, tok
*/
export function buildErrorsCsv(entries, headers, mapping) {
const mappedHeaders = headers.filter((h) => mapping[h]);
- const lines = [[...mappedHeaders, 'Error'].map(csvEscape).join(',')];
+ const lines = [[...mappedHeaders, 'Error'].map(csvField).join(',')];
for (const entry of entries) {
if (entry.errors.length === 0) continue;
const values = mappedHeaders.map((h) => entry.row[mapping[h]]);
const errorText = entry.errors.map((e) => (e.target ? `${e.target}: ${e.message}` : e.message)).join(' | ');
- lines.push([...values, errorText].map(csvEscape).join(','));
+ lines.push([...values, errorText].map(csvField).join(','));
}
return lines.join('\n');
}
diff --git a/packages/app-shell-core/src/components/import/__tests__/ImportDialog.labels.test.jsx b/packages/app-shell-core/src/components/import/__tests__/ImportDialog.labels.test.jsx
new file mode 100644
index 000000000..8ecda1a28
--- /dev/null
+++ b/packages/app-shell-core/src/components/import/__tests__/ImportDialog.labels.test.jsx
@@ -0,0 +1,209 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
+
+// ETP-4669: ImportDialog is the single point that fans the nested `labels` prop out to every
+// child of the import flow. These tests mock each child so we can assert the EXACT sub-slice
+// each one receives (by object identity), not merely that something rendered — a mis-wiring
+// (e.g. forwarding `labels.confirm` where `labels.mapping` belongs) would slip past a
+// render-only assertion but is caught here. The root-level chrome (title, downloadTemplate,
+// importButton) is asserted through the real DOM, since ImportDialog renders it itself.
+
+const captured = vi.hoisted(() => ({}));
+
+vi.mock('../ImportDropzone.jsx', () => ({
+ ImportDropzone: (props) => {
+ captured.dropzone = props;
+ return (
+
+
+ );
+ },
+}));
+
+vi.mock('../ImportColumnMapping.jsx', () => ({
+ ImportColumnMapping: (props) => {
+ captured.mapping = props;
+ return ;
+ },
+}));
+
+// ImportDialog imports both ImportReviewQueue and buildErrorsCsv from this module — the mock
+// must preserve both named exports or the import itself breaks.
+vi.mock('../ImportReviewQueue.jsx', () => ({
+ buildErrorsCsv: () => '',
+ ImportReviewQueue: (props) => {
+ captured.reviewQueue = props;
+ return ;
+ },
+}));
+
+vi.mock('../ImportConfirmStep.jsx', () => ({
+ ImportConfirmStep: (props) => {
+ captured.confirm = props;
+ return props.onConfirm()} />;
+ },
+}));
+
+vi.mock('../ImportProgressStep.jsx', () => ({
+ ImportProgressStep: (props) => {
+ captured.progress = props;
+ return ;
+ },
+}));
+
+vi.mock('../ImportFileErrorDialog.jsx', () => ({
+ ImportFileErrorDialog: (props) => {
+ captured.fileError = props;
+ return ;
+ },
+}));
+
+vi.mock('../ImportSystemErrorDialog.jsx', () => ({
+ ImportSystemErrorDialog: (props) => {
+ captured.systemError = props;
+ return props.open ? : null;
+ },
+}));
+
+import { ImportDialog } from '../ImportDialog.jsx';
+
+// The real Radix Dialog stays mounted (only the step children are mocked); jsdom needs the
+// same observer/pointer polyfills the sibling ImportDialog.test.jsx already installs.
+if (!global.ResizeObserver) {
+ global.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+}
+if (!Element.prototype.hasPointerCapture) Element.prototype.hasPointerCapture = () => false;
+if (!Element.prototype.releasePointerCapture) Element.prototype.releasePointerCapture = () => {};
+if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
+
+const config = {
+ spec: 'contacts',
+ entity: 'businessPartner',
+ fields: [
+ { target: 'name', label: 'Name', required: true },
+ { target: 'email', label: 'Email', isEmail: true },
+ ],
+ dedupe: { scope: 'file', key: ['email'] },
+};
+
+// Distinct sentinel per slice — every child's sub-slice is a unique object, so an identity
+// (toBe) assertion proves the RIGHT slice reached the RIGHT child, not just any label.
+const labels = {
+ title: 'L_title',
+ revalidating: 'L_revalidating',
+ downloadTemplate: 'L_downloadTemplate',
+ importButton: (n) => `L_import_${n}`,
+ dropzone: { dropHere: 'L_dropHere', dropHint: 'L_dropHint' },
+ progress: { title: 'L_progressTitle', subtitle: 'L_progressSubtitle' },
+ mapping: { notImported: 'L_notImported', mappedSummary: 'L_mappedSummary', editMatch: 'L_editMatch', editTitle: 'L_editTitle', save: 'L_save', cancel: 'L_cancel' },
+ confirm: { title: 'L_confirmTitle', willImport: (n) => `L_willImport_${n}`, willSkip: (n) => `L_willSkip_${n}`, cancel: 'L_cancel', confirm: 'L_confirm' },
+ fileError: { title: 'L_fileErrorTitle', cancel: 'L_cancel', retry: 'L_retry' },
+ reviewQueue: { filterAll: 'L_filterAll', filterError: 'L_filterError', skip: 'L_skip', retry: 'L_rqRetry' },
+ systemError: { title: 'L_sysTitle', subtitle: 'L_sysSubtitle', close: 'L_sysClose' },
+};
+
+function renderDialog(props = {}) {
+ return render(
+ {}}
+ labels={labels}
+ {...props}
+ />,
+ );
+}
+
+async function driveToMapping() {
+ fireEvent.click(screen.getByTestId('mock-dropzone-select'));
+ await waitFor(() => screen.getByTestId('mock-mapping'));
+}
+
+beforeEach(() => {
+ for (const k of Object.keys(captured)) delete captured[k];
+});
+afterEach(() => cleanup());
+
+describe('ImportDialog — label forwarding to every child', () => {
+ it('forwards labels.dropzone to ImportDropzone and renders the root chrome (title, downloadTemplate) from the merged labels', () => {
+ renderDialog();
+ expect(captured.dropzone.labels).toBe(labels.dropzone);
+ expect(screen.getByTestId('DialogTitle__38a6c3').textContent).toBe('L_title');
+ expect(screen.getByTestId('ImportDialog__downloadTemplate').textContent).toBe('L_downloadTemplate');
+ });
+
+ it('always mounts ImportSystemErrorDialog with labels.systemError (closed until a failure occurs)', () => {
+ renderDialog();
+ expect(captured.systemError.labels).toBe(labels.systemError);
+ expect(captured.systemError.open).toBe(false);
+ });
+
+ it('forwards labels.mapping to ImportColumnMapping, labels.reviewQueue to ImportReviewQueue, and interpolates the import button with the valid-row count', async () => {
+ renderDialog();
+ await driveToMapping();
+ expect(captured.mapping.labels).toBe(labels.mapping);
+ expect(captured.reviewQueue.labels).toBe(labels.reviewQueue);
+ // Pre-send queue has no retry affordance — the (n) => string chrome label is what renders
+ // on the real Import button, proving importButton() is called with the count, not a constant.
+ expect(captured.reviewQueue.retryLabel).toBeUndefined();
+ expect(screen.getByTestId('ImportDialog__importButton').textContent).toBe('L_import_1');
+ });
+
+ it('forwards labels.confirm to ImportConfirmStep', async () => {
+ renderDialog();
+ await driveToMapping();
+ fireEvent.click(screen.getByTestId('ImportDialog__importButton'));
+ await waitFor(() => screen.getByTestId('mock-confirm'));
+ expect(captured.confirm.labels).toBe(labels.confirm);
+ });
+
+ it('forwards labels.progress to ImportProgressStep while a send is in flight', async () => {
+ // A postBatch that never resolves pins the dialog on the SENDING step so the progress
+ // child is deterministically observable (not a transient flash between confirm and result).
+ const postBatch = vi.fn(() => new Promise(() => {}));
+ renderDialog({ postBatch });
+ await driveToMapping();
+ fireEvent.click(screen.getByTestId('ImportDialog__importButton'));
+ fireEvent.click(await screen.findByTestId('mock-confirm'));
+ await waitFor(() => expect(captured.progress).toBeTruthy());
+ expect(captured.progress.labels).toBe(labels.progress);
+ });
+
+ it('forwards labels.reviewQueue + retryLabel to the result-step queue and labels.systemError (now open) after a failed send', async () => {
+ // Classified FAILED (unrecognized backend message) → the result step renders the review
+ // queue with a retry affordance AND opens the system-error dialog.
+ const postBatch = vi.fn().mockResolvedValue({ message: 'boom-unrecognized' });
+ renderDialog({ postBatch });
+ await driveToMapping();
+ fireEvent.click(screen.getByTestId('ImportDialog__importButton'));
+ fireEvent.click(await screen.findByTestId('mock-confirm'));
+ await waitFor(() => expect(captured.systemError.open).toBe(true));
+ expect(captured.reviewQueue.labels).toBe(labels.reviewQueue);
+ expect(captured.reviewQueue.retryLabel).toBe(labels.reviewQueue.retry);
+ expect(captured.systemError.labels).toBe(labels.systemError);
+ });
+
+ it('forwards labels.fileError to ImportFileErrorDialog when a file fails to parse', async () => {
+ renderDialog();
+ fireEvent.click(screen.getByTestId('mock-dropzone-bad'));
+ await waitFor(() => screen.getByTestId('mock-fileError'));
+ expect(captured.fileError.labels).toBe(labels.fileError);
+ });
+});
diff --git a/packages/app-shell-core/src/components/import/__tests__/ImportDialog.test.jsx b/packages/app-shell-core/src/components/import/__tests__/ImportDialog.test.jsx
index 8b492d919..3e559c051 100644
--- a/packages/app-shell-core/src/components/import/__tests__/ImportDialog.test.jsx
+++ b/packages/app-shell-core/src/components/import/__tests__/ImportDialog.test.jsx
@@ -136,7 +136,10 @@ describe('ImportDialog', () => {
await waitFor(() => expect(onImported).toHaveBeenCalledWith({ okCount: 0, failedCount: 1 }));
// The dialog itself must still be showing the review queue at this point, not
// already torn down — this is what a caller closing on every onImported call hides.
- expect(screen.getByTestId('ImportReviewQueue__rowError-0').textContent).toContain('Invalid value for OBTIKTaxIDKey');
+ // ETP-4669: the row now shows a friendly, classified message — the raw backend text
+ // ("Invalid value for OBTIKTaxIDKey", an uncontrolled leak) is no longer rendered here;
+ // it is preserved on error.raw for the system-error dialog's report.
+ expect(screen.getByTestId('ImportReviewQueue__rowError-0').textContent).toMatch(/could not be imported/i);
});
it('regression: shows the ImportSystemErrorDialog with the last failure\'s message, row data, request sent, and raw trace after a failed send', async () => {
@@ -156,7 +159,10 @@ describe('ImportDialog', () => {
fireEvent.click(screen.getByTestId('ImportDialog__importButton'));
fireEvent.click(screen.getByTestId('ImportConfirmStep__confirm'));
await waitFor(() => screen.getByTestId('ImportSystemErrorDialog__title'));
- expect(screen.getByTestId('ImportSystemErrorDialog__message').textContent).toBe("Operation 'bp' rejected by server");
+ // ETP-4669: the prominent message is the friendly, classified text — never the raw
+ // wrapper ("Operation 'bp' rejected by server"). The raw trace still carries the real
+ // backend text and is shown (collapsed) under "View full report".
+ expect(screen.getByTestId('ImportSystemErrorDialog__message').textContent).toMatch(/could not be imported/i);
expect(screen.queryByTestId('ImportSystemErrorDialog__trace')).toBeNull();
fireEvent.click(screen.getByTestId('ImportSystemErrorDialog__toggleReport'));
expect(screen.getByTestId('ImportSystemErrorDialog__row').textContent).toContain('Lucia');
diff --git a/packages/app-shell-core/src/lib/csv/__tests__/csvSerializer.test.js b/packages/app-shell-core/src/lib/csv/__tests__/csvSerializer.test.js
new file mode 100644
index 000000000..c2d0d266f
--- /dev/null
+++ b/packages/app-shell-core/src/lib/csv/__tests__/csvSerializer.test.js
@@ -0,0 +1,101 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { csvField } from '../csvSerializer.js';
+
+describe('csvField', () => {
+ describe('neutralizes spreadsheet formula injection', () => {
+ it('prepends an apostrophe to a value starting with =', () => {
+ assert.equal(csvField('=1+1'), "'=1+1");
+ });
+
+ it('prepends an apostrophe to a value starting with +', () => {
+ assert.equal(csvField('+SUM(A1:A2)'), "'+SUM(A1:A2)");
+ });
+
+ it('prepends an apostrophe to a value starting with -', () => {
+ assert.equal(csvField('-CMD'), "'-CMD");
+ });
+
+ it('prepends an apostrophe to a value starting with @', () => {
+ assert.equal(csvField('@SUM(A1:A2)'), "'@SUM(A1:A2)");
+ });
+
+ it('catches a marker hiding behind a leading tab', () => {
+ assert.equal(csvField('\t=1+1'), "'\t=1+1");
+ });
+
+ it('catches a marker hiding behind a leading carriage return', () => {
+ assert.equal(csvField('\r=1+1'), "'\r=1+1");
+ });
+
+ it('catches a marker hiding behind leading spaces', () => {
+ assert.equal(csvField(' =1+1'), "' =1+1");
+ });
+
+ it('catches a marker hiding behind a leading line feed (and quotes it, since it now contains a raw newline)', () => {
+ assert.equal(csvField('\n=1+1'), '"\'\n=1+1"');
+ });
+
+ it('neutralizes AND quotes when the value also needs RFC 4180 quoting', () => {
+ assert.equal(csvField('=1+1,extra'), '"\'=1+1,extra"');
+ });
+ });
+
+ describe('does not double-prefix or break legitimate values', () => {
+ it('does not add a second apostrophe to an already-neutralized value', () => {
+ assert.equal(csvField("'=1+1"), "'=1+1");
+ });
+
+ it('neutralizes a legitimate negative number (documented trade-off, same as the server policy)', () => {
+ assert.equal(csvField('-500.00'), "'-500.00");
+ });
+
+ it('leaves ordinary text untouched', () => {
+ assert.equal(csvField('Normal Value'), 'Normal Value');
+ });
+
+ it('leaves zero untouched', () => {
+ assert.equal(csvField(0), '0');
+ });
+
+ it('returns an empty string for null', () => {
+ assert.equal(csvField(null), '');
+ });
+
+ it('returns an empty string for undefined', () => {
+ assert.equal(csvField(undefined), '');
+ });
+
+ it('returns an empty string for an empty string', () => {
+ assert.equal(csvField(''), '');
+ });
+
+ it('quotes a value containing a comma', () => {
+ assert.equal(csvField('texto, con coma'), '"texto, con coma"');
+ });
+
+ it('escapes embedded double quotes and quotes the field', () => {
+ assert.equal(csvField('con "comillas" internas'), '"con ""comillas"" internas"');
+ });
+
+ it('preserves an embedded (non-leading) line feed and quotes the field', () => {
+ assert.equal(csvField('line one\nline two'), '"line one\nline two"');
+ });
+
+ it('preserves an embedded (non-leading) CRLF and quotes the field', () => {
+ assert.equal(csvField('line one\r\nline two'), '"line one\r\nline two"');
+ });
+
+ it('preserves Unicode characters', () => {
+ assert.equal(csvField('José Ñáñez'), 'José Ñáñez');
+ });
+
+ it('leaves a safe header untouched', () => {
+ assert.equal(csvField('Commercial Name'), 'Commercial Name');
+ });
+
+ it('is deterministic across repeated calls', () => {
+ assert.equal(csvField('=1+1'), csvField('=1+1'));
+ });
+ });
+});
diff --git a/packages/app-shell-core/src/lib/csv/csvSerializer.js b/packages/app-shell-core/src/lib/csv/csvSerializer.js
new file mode 100644
index 000000000..ca7f98bc2
--- /dev/null
+++ b/packages/app-shell-core/src/lib/csv/csvSerializer.js
@@ -0,0 +1,26 @@
+const FORMULA_TRIGGER_CHARS = '=+-@';
+
+/**
+ * A value is formula-sensitive when its first non-whitespace character is a spreadsheet
+ * formula trigger. Leading whitespace/control characters (space, tab, CR, LF) are skipped
+ * first so a marker cannot hide behind them. Mirrors the server-side policy in
+ * NeoCsvExportService.java (com.etendoerp.go) so both sides neutralize the same inputs.
+ */
+function isFormulaInjection(value) {
+ let i = 0;
+ while (i < value.length && /\s/.test(value[i])) i++;
+ return i < value.length && FORMULA_TRIGGER_CHARS.includes(value[i]);
+}
+
+/**
+ * Serializes a single CSV field: neutralizes spreadsheet formula injection (CWE-1236) by
+ * prepending an apostrophe when the value is formula-sensitive, then applies RFC 4180
+ * quoting (only when the value contains a comma, quote, or newline).
+ */
+export function csvField(value) {
+ let s = String(value ?? '');
+ if (isFormulaInjection(s)) {
+ s = `'${s}`;
+ }
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+}
diff --git a/packages/app-shell-core/src/lib/import/__tests__/importEngine.i18n.test.js b/packages/app-shell-core/src/lib/import/__tests__/importEngine.i18n.test.js
new file mode 100644
index 000000000..809e5002c
--- /dev/null
+++ b/packages/app-shell-core/src/lib/import/__tests__/importEngine.i18n.test.js
@@ -0,0 +1,151 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { classifyImportError, sendRow, SEND_STATUS } from '../importEngine.js';
+
+// ETP-4669: importEngine now classifies raw backend diagnostics into stable, translatable
+// error KINDS and never surfaces the raw text as the user-facing message (it moves to
+// error.raw). The existing importEngine.test.js covers the English-fallback path (no
+// translator); these cover the classifier directly and the translator-injected path — the
+// two behaviors the two-repo wiring depends on but that were previously untested here.
+
+describe('classifyImportError', () => {
+ it('maps a Postgres not-null column violation to a required-field kind with a readable field name', () => {
+ const c = classifyImportError('null value in column "commercial_name" of relation "c_bpartner" violates not-null constraint');
+ assert.equal(c.key, 'importErrorRequiredField');
+ assert.equal(c.params.field, 'Commercial Name');
+ assert.equal(c.duplicate, false);
+ });
+
+ it('maps a bare not-null constraint (no column captured) to the generic required kind', () => {
+ const c = classifyImportError('ERROR: violates not-null constraint');
+ assert.equal(c.key, 'importErrorRequiredGeneric');
+ assert.equal(c.duplicate, false);
+ });
+
+ it('maps a Postgres unique-index violation to the duplicate kind (benign, retry-proof)', () => {
+ const c = classifyImportError('duplicate key value violates unique constraint "c_bpartner_value_uk"');
+ assert.equal(c.key, 'importErrorDuplicate');
+ assert.equal(c.duplicate, true);
+ });
+
+ it('maps Etendo\'s AD-level uniqueness message (English and Spanish) to the duplicate-identifier kind', () => {
+ const en = classifyImportError('There is already a Business Partner with the same (Client, Organization, Search Key). (Client, Organization, Search Key) must be unique.');
+ const es = classifyImportError('Ya existe un Tercero con la misma (Cliente, Organización, Clave de búsqueda). (Cliente, Organización, Clave de búsqueda) debe ser único.');
+ assert.equal(en.key, 'importErrorDuplicateIdentifier');
+ assert.equal(en.duplicate, true);
+ assert.equal(es.key, 'importErrorDuplicateIdentifier');
+ assert.equal(es.duplicate, true);
+ });
+
+ it('maps a duplicate-username collision to its own kind, flagged actionable (duplicate:false) — the contact is not a duplicate, only the derived AD_User name is', () => {
+ // No "must be unique" wording, so it must NOT fall into the earlier duplicate-identifier
+ // branch (which is duplicate:true / benign-skip). Its own branch keeps it actionable.
+ const c = classifyImportError('A user with the same name already exists. Enter a different name for this user');
+ assert.equal(c.key, 'importErrorDuplicateUser');
+ assert.equal(c.duplicate, false);
+ assert.deepEqual(c.params, {});
+ });
+
+ it('maps a value-too-long validation to its own kind', () => {
+ const c = classifyImportError('Value too long. Length 48, maximum allowed 40');
+ assert.equal(c.key, 'importErrorValueTooLong');
+ assert.equal(c.duplicate, false);
+ });
+
+ it('falls through to the generic kind for an uncontrolled backend leak (never a partial match on internal noise)', () => {
+ const c = classifyImportError('Invalid value for OBTIKTaxIDKey: com.etendoerp.redis.interfaces.CachedSet@55b0cf12');
+ assert.equal(c.key, 'importErrorGeneric');
+ assert.equal(c.duplicate, false);
+ });
+
+ it('is total over non-string input — a null/undefined/number diagnostic classifies as generic, never throws', () => {
+ for (const bad of [null, undefined, 42, {}, []]) {
+ const c = classifyImportError(bad);
+ assert.equal(c.key, 'importErrorGeneric');
+ assert.equal(c.duplicate, false);
+ }
+ });
+});
+
+// A translator echoing a small Spanish table; returning the key unchanged is the "missing
+// key" signal the engine's fallback guard checks for.
+const es = {
+ importErrorRequiredField: (p) => `El campo "${p.field}" es obligatorio.`,
+ importErrorDuplicateIdentifier: () => 'Ya existe un registro con este identificador.',
+ importErrorDuplicateUser: () => 'Ya existe un usuario con este nombre para el contacto. Use un nombre diferente.',
+ importErrorValueTooLong: () => 'Un valor es demasiado largo.',
+ importErrorGeneric: () => 'No se pudo importar esta fila. Abra el detalle para ver el reporte técnico.',
+};
+const translate = (key, params) => (es[key] ? es[key](params ?? {}) : key);
+
+describe('sendRow — translator-injected friendly messages', () => {
+ it('localizes a classified required-field error and interpolates the field name, keeping the raw text on error.raw', async () => {
+ const rawText = 'null value in column "value" of relation "c_bpartner" violates not-null constraint';
+ const postBatch = async () => ({ committed: false, error: { message: rawText } });
+ const result = await sendRow([{ id: 'row' }], { postBatch, translate });
+ assert.equal(result.status, SEND_STATUS.FAILED);
+ assert.equal(result.error.message, 'El campo "Value" es obligatorio.');
+ assert.ok(result.error.raw.includes('null value in column'), `expected raw to keep the backend text, got: ${result.error.raw}`);
+ });
+
+ it('localizes an uncontrolled leak to the generic message — the raw CachedSet text never reaches the user but stays on error.raw', async () => {
+ const postBatch = async () => ({ message: 'Invalid value for OBTIKTaxIDKey: com.etendoerp.redis.interfaces.CachedSet@1a2b3c' });
+ const result = await sendRow([{ id: 'row' }], { postBatch, translate });
+ assert.equal(result.status, SEND_STATUS.FAILED);
+ assert.equal(result.error.message, es.importErrorGeneric());
+ assert.ok(!result.error.message.includes('CachedSet'), `expected no raw leak in the message, got: ${result.error.message}`);
+ assert.ok(result.error.raw.includes('CachedSet'), `expected raw to keep the backend text, got: ${result.error.raw}`);
+ });
+
+ it('localizes a unique-constraint rejection as a DUPLICATE with a friendly identifier message', async () => {
+ const postBatch = async () => ({
+ committed: false,
+ error: { message: "Operation 'bp' rejected by server", detail: { error: { message: 'There is already a Business Partner with the same (Client, Organization, Search Key). (Client, Organization, Search Key) must be unique.' } } },
+ });
+ const result = await sendRow([{ id: 'bp' }], { postBatch, translate });
+ assert.equal(result.status, SEND_STATUS.DUPLICATE);
+ assert.equal(result.error.message, 'Ya existe un registro con este identificador.');
+ assert.ok(!result.error.message.includes('Business Partner'), `expected no raw AD text leak, got: ${result.error.message}`);
+ assert.ok(result.error.raw.includes('must be unique'), `expected raw to keep the backend text, got: ${result.error.raw}`);
+ });
+
+ it('classifies a duplicate-username collision as an actionable FAILED (not DUPLICATE), with a friendly message and the raw text on error.raw — translator injected and not', async () => {
+ // Etendo's derived-username collision arrives as a top-level error.message, status 500, no
+ // .detail. duplicate:false means SEND_STATUS.FAILED (stays in the queue, counts toward
+ // failedCount) — never silently skipped like a benign already-exists row.
+ const rawText = 'A user with the same name already exists. Enter a different name for this user';
+ const postBatch = async () => ({ committed: false, error: { message: rawText, status: 500 } });
+
+ const withT = await sendRow([{ id: 'bp' }], { postBatch, translate });
+ assert.equal(withT.status, SEND_STATUS.FAILED);
+ assert.equal(withT.status !== SEND_STATUS.DUPLICATE, true);
+ assert.equal(withT.error.message, es.importErrorDuplicateUser());
+ assert.notEqual(withT.error.message, rawText);
+ assert.ok(withT.error.raw.includes('user with the same name already exists'), `expected raw to keep the backend text, got: ${withT.error.raw}`);
+
+ const noT = await sendRow([{ id: 'bp' }], { postBatch });
+ assert.equal(noT.status, SEND_STATUS.FAILED);
+ assert.equal(noT.error.message, 'A user with this name already exists for the contact. Try a different name.');
+ assert.notEqual(noT.error.message, rawText);
+ assert.ok(noT.error.raw.includes('user with the same name already exists'), `expected raw to keep the backend text, got: ${noT.error.raw}`);
+ });
+
+ it('falls back to the English default when the injected translator has no entry for the classified key (returns the key unchanged)', async () => {
+ // A translator that resolves NOTHING (echoes every key) must not leak the bare key as the
+ // user-facing message — the engine detects `translated === key` and uses its English default.
+ const echoKey = (key) => key;
+ const postBatch = async () => ({ message: 'some unrecognized backend failure' });
+ const result = await sendRow([{ id: 'row' }], { postBatch, translate: echoKey });
+ assert.equal(result.status, SEND_STATUS.FAILED);
+ assert.notEqual(result.error.message, 'importErrorGeneric');
+ assert.match(result.error.message, /could not be imported/i);
+ });
+
+ it('with no translator injected, uses the English fallback table and still interpolates params', async () => {
+ const postBatch = async () => ({ committed: false, error: { message: 'null value in column "search_key" of relation "c_bpartner" violates not-null constraint' } });
+ const result = await sendRow([{ id: 'row' }], { postBatch });
+ assert.equal(result.status, SEND_STATUS.FAILED);
+ assert.equal(result.error.message, 'The field "Search Key" is required.');
+ assert.ok(result.error.raw.includes('null value in column'), `expected raw to keep the backend text, got: ${result.error.raw}`);
+ });
+});
diff --git a/packages/app-shell-core/src/lib/import/__tests__/importEngine.test.js b/packages/app-shell-core/src/lib/import/__tests__/importEngine.test.js
index bd27f1d4c..2b907843c 100644
--- a/packages/app-shell-core/src/lib/import/__tests__/importEngine.test.js
+++ b/packages/app-shell-core/src/lib/import/__tests__/importEngine.test.js
@@ -10,11 +10,16 @@ describe('sendRow', () => {
assert.equal(result.recordId, 'REC-1');
});
- it('returns FAILED with the server error on a committed:false response', async () => {
+ it('returns FAILED on a committed:false response, surfacing a friendly message with the raw text preserved on error.raw', async () => {
+ // ETP-4669: an unrecognized backend message is no longer shown to the user verbatim —
+ // it is classified to a friendly generic fallback, while the raw text stays on error.raw
+ // for the console/telemetry and the system-error dialog's report.
const postBatch = async () => ({ committed: false, failedAt: { index: 0 }, error: { message: 'Rejected' } });
const result = await sendRow([{ id: 'row' }], { postBatch });
assert.equal(result.status, SEND_STATUS.FAILED);
- assert.equal(result.error.message, 'Rejected');
+ assert.notEqual(result.error.message, 'Rejected');
+ assert.match(result.error.message, /could not be imported/i);
+ assert.ok(result.error.raw.includes('Rejected'), `expected raw to preserve the backend text, got: ${result.error.raw}`);
});
it('regression: classifies a unique-constraint rejection as DUPLICATE, not FAILED — nothing for the user to fix or retry', async () => {
@@ -53,13 +58,14 @@ describe('sendRow', () => {
});
const result = await sendRow([{ id: 'bp' }], { postBatch });
assert.equal(result.status, SEND_STATUS.DUPLICATE);
- assert.equal(
- result.error.message,
- 'There is already a Business Partner with the same (Client, Organization, Search Key). (Client, Organization, Search Key) must be unique. You must change the values entered.',
- );
+ // ETP-4669: the user sees a friendly duplicate message, not the raw AD text (which
+ // names technical field groups). The raw text is preserved on error.raw.
+ assert.match(result.error.message, /already exists/i);
+ assert.ok(!result.error.message.includes('Business Partner'), `expected no raw AD text leak, got: ${result.error.message}`);
+ assert.ok(result.error.raw.includes('must be unique'), `expected raw to preserve the backend text, got: ${result.error.raw}`);
});
- it('regression: a real /batch failure with a non-duplicate nested message surfaces that message and stays FAILED', async () => {
+ it('regression: a real /batch failure with a non-duplicate nested message stays FAILED and preserves the raw text on error.raw', async () => {
const postBatch = async () => ({
committed: false,
failedAt: { index: 0, id: 'bp' },
@@ -71,7 +77,10 @@ describe('sendRow', () => {
});
const result = await sendRow([{ id: 'bp' }], { postBatch });
assert.equal(result.status, SEND_STATUS.FAILED);
- assert.equal(result.error.message, 'Could not find Sequence for: EM_Etgo_Identifier');
+ // ETP-4669: unrecognized backend text is not shown verbatim — a friendly generic
+ // message is shown, with the real text kept on error.raw for the report.
+ assert.match(result.error.message, /could not be imported/i);
+ assert.ok(result.error.raw.includes('Could not find Sequence for: EM_Etgo_Identifier'), `expected raw to preserve the backend text, got: ${result.error.raw}`);
});
it('regression: a genuinely different failure message is still classified as FAILED, not DUPLICATE', async () => {
@@ -92,26 +101,27 @@ describe('sendRow', () => {
assert.equal(result.status, SEND_STATUS.UNKNOWN);
});
- it('regression: surfaces the real message from a raw exception response ({ message } shape, not the documented { error } wrapper)', async () => {
- // Confirmed via a live capture: an unhandled server-side exception (a genuine 500,
- // not a graceful BatchService.java transactional rollback) never goes through the
- // documented `{ committed: false, error: { message, ... } }` shape at all — it comes
- // back as Etendo's generic error envelope, `{ message: "..." }`, with no `.error` key
- // (useBatch's runBatch returns this unmodified whenever the non-ok body happens to
- // parse as JSON). Reading only `response.error` for that shape silently produced
- // `error: undefined`, so the actual backend exception text never reached the UI —
- // the user saw nothing more useful than a generic "Unknown error" bubble.
+ it('regression (ETP-4669): an uncontrolled backend leak ({ message } shape) never reaches the user as-is — friendly message, raw kept on error.raw', async () => {
+ // Confirmed via a live capture: an unhandled server-side exception (a genuine 500, not
+ // a graceful BatchService.java transactional rollback) comes back as Etendo's generic
+ // envelope, `{ message: "..." }`, with no `.error` key. The exception text here is an
+ // unserialized Redis CachedSet reference (the sibling ETP-4668 backend bug) — exactly
+ // the kind of raw, meaningless-to-the-user leak that must NOT be shown in the bubble.
+ // It is classified to a friendly generic message; the raw text stays on error.raw for
+ // the system-error dialog's report and the console.
const postBatch = async () => ({ message: 'Invalid value for OBTIKTaxIDKey: some.CachedSet@1a2b3c' });
const result = await sendRow([{ id: 'row' }], { postBatch });
assert.equal(result.status, SEND_STATUS.FAILED);
- assert.equal(result.error.message, 'Invalid value for OBTIKTaxIDKey: some.CachedSet@1a2b3c');
+ assert.ok(!result.error.message.includes('CachedSet'), `expected the raw leak to be hidden from the user, got: ${result.error.message}`);
+ assert.match(result.error.message, /could not be imported/i);
+ assert.ok(result.error.raw.includes('CachedSet'), `expected raw to preserve the backend text, got: ${result.error.raw}`);
});
- it('regression: falls back to a generic message only when the response has neither shape', async () => {
+ it('regression: falls back to a friendly generic message when the response has neither shape', async () => {
const postBatch = async () => ({});
const result = await sendRow([{ id: 'row' }], { postBatch });
assert.equal(result.status, SEND_STATUS.FAILED);
- assert.equal(result.error.message, 'Unknown error');
+ assert.match(result.error.message, /could not be imported/i);
});
it('regression: carries error.detail through as a readable raw trace (the underlying NEO error, not just the generic wrapper message)', async () => {
@@ -163,12 +173,15 @@ describe('sendRow', () => {
});
const result = await sendRow([{ id: 'bp' }], { postBatch });
assert.equal(result.status, SEND_STATUS.FAILED);
- assert.ok(result.error.message.includes('searchKey'), `expected message to include the field name, got: ${result.error.message}`);
- assert.ok(result.error.message.includes('Value too long'), `expected message to include the validation text, got: ${result.error.message}`);
+ // ETP-4669: a "value too long" validation is classified to a friendly message; the raw
+ // field-level text (which leaks the technical field name searchKey) is kept on error.raw.
+ assert.match(result.error.message, /too long/i);
+ assert.ok(!result.error.message.includes('searchKey'), `expected no technical field-name leak, got: ${result.error.message}`);
assert.ok(!result.error.message.includes("rejected by server"), `expected the generic wrapper text to be replaced, got: ${result.error.message}`);
+ assert.ok(result.error.raw.includes('Value too long'), `expected raw to preserve the validation text, got: ${result.error.raw}`);
});
- it('regression: joins multiple field validation messages from error.detail.response.errors into one readable string', async () => {
+ it('regression: joins multiple field validation messages from error.detail.response.errors into the raw trace', async () => {
const postBatch = async () => ({
committed: false,
failedAt: { index: 0, id: 'bp' },
@@ -188,13 +201,15 @@ describe('sendRow', () => {
});
const result = await sendRow([{ id: 'bp' }], { postBatch });
assert.equal(result.status, SEND_STATUS.FAILED);
- assert.ok(result.error.message.includes('searchKey'), `expected message to include searchKey, got: ${result.error.message}`);
- assert.ok(result.error.message.includes('Value too long'), `expected message to include the searchKey text, got: ${result.error.message}`);
- assert.ok(result.error.message.includes('email'), `expected message to include email, got: ${result.error.message}`);
- assert.ok(result.error.message.includes('Invalid email format'), `expected message to include the email text, got: ${result.error.message}`);
+ // ETP-4669: the user sees one friendly message; the joined field-level detail (both the
+ // searchKey and email messages) is preserved on error.raw for the report.
+ assert.match(result.error.message, /too long|could not be imported/i);
+ assert.ok(!result.error.message.includes('searchKey'), `expected no technical field-name leak, got: ${result.error.message}`);
+ assert.ok(result.error.raw.includes('searchKey') && result.error.raw.includes('Value too long'), `expected raw to include the searchKey detail, got: ${result.error.raw}`);
+ assert.ok(result.error.raw.includes('email') && result.error.raw.includes('Invalid email format'), `expected raw to include the email detail, got: ${result.error.raw}`);
});
- it('regression: prefers error.detail.error.message over error.detail.response.errors when both happen to be present (documented priority order)', async () => {
+ it('regression: prefers error.detail.error.message over error.detail.response.errors when both are present (classification priority)', async () => {
const postBatch = async () => ({
committed: false,
failedAt: { index: 0, id: 'bp' },
@@ -208,11 +223,11 @@ describe('sendRow', () => {
},
});
const result = await sendRow([{ id: 'bp' }], { postBatch });
+ // The nested duplicate message (detail.error.message) wins over the value-too-long
+ // validation shape, so the outcome is DUPLICATE and the raw duplicate text is preserved.
assert.equal(result.status, SEND_STATUS.DUPLICATE);
- assert.equal(
- result.error.message,
- 'There is already a Business Partner with the same (Client, Organization, Search Key). (Client, Organization, Search Key) must be unique.',
- );
+ assert.match(result.error.message, /already exists/i);
+ assert.ok(result.error.raw.includes('must be unique'), `expected raw to preserve the duplicate text, got: ${result.error.raw}`);
});
});
diff --git a/packages/app-shell-core/src/lib/import/buildTemplateCsv.js b/packages/app-shell-core/src/lib/import/buildTemplateCsv.js
index 994cd0393..102742cd5 100644
--- a/packages/app-shell-core/src/lib/import/buildTemplateCsv.js
+++ b/packages/app-shell-core/src/lib/import/buildTemplateCsv.js
@@ -1,7 +1,4 @@
-function csvEscape(value) {
- const s = String(value ?? '');
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
-}
+import { csvField } from '../csv/csvSerializer.js';
/**
* Builds a blank (header-row-only) CSV template for a window's import
@@ -13,6 +10,6 @@ function csvEscape(value) {
*/
export function buildTemplateCsv(fields) {
return fields
- .map((field) => csvEscape(field.aliases?.[0] ?? field.label ?? field.target))
+ .map((field) => csvField(field.aliases?.[0] ?? field.label ?? field.target))
.join(',');
}
diff --git a/packages/app-shell-core/src/lib/import/importEngine.js b/packages/app-shell-core/src/lib/import/importEngine.js
index 5b05eca4c..407e554d2 100644
--- a/packages/app-shell-core/src/lib/import/importEngine.js
+++ b/packages/app-shell-core/src/lib/import/importEngine.js
@@ -8,16 +8,96 @@ export class BatchTimeoutError extends Error {
export const SEND_STATUS = { OK: 'ok', FAILED: 'failed', UNKNOWN: 'unknown', DUPLICATE: 'duplicate' };
/**
- * Etendo's generic AD-level uniqueness-constraint message ("X must be unique") — the same
- * wording for any entity's unique index, not something specific to BusinessPartner
- * (confirmed via a real capture: "There is already a Business Partner with the same
- * (Client, Organization, Search Key). (Client, Organization, Search Key) must be unique.").
- * A row that already exists server-side isn't a failure the user needs to act on or
- * retry — retrying would only repeat the same rejection — so it's classified and reported
- * separately from a genuine FAILED/UNKNOWN outcome.
+ * English fallbacks for each classified error KIND — used verbatim only when the caller
+ * injects no `translate` function. This mirrors the DEFAULT_LABELS fallback the import UI
+ * components already use: a controlled, friendly default, NEVER the raw backend text. The
+ * raw text is always preserved separately on `error.raw` for the console/telemetry and the
+ * system-error dialog's collapsible report — it is just never the user-facing `message`.
+ * Keys match the genericLabels keys the functional app defines, so an injected `translate`
+ * (e.g. useUI's `ui`) resolves the same key to a localized string.
*/
-function isDuplicateKeyError(message) {
- return typeof message === 'string' && /must be unique/i.test(message);
+const IMPORT_ERROR_FALLBACKS = {
+ importErrorRequiredField: (p) => `The field "${p.field}" is required.`,
+ importErrorRequiredGeneric: () => 'A required field is missing.',
+ importErrorDuplicate: () => 'A record with the same value already exists.',
+ importErrorDuplicateIdentifier: () => 'A record with this identifier already exists.',
+ importErrorDuplicateUser: () => 'A user with this name already exists for the contact. Try a different name.',
+ importErrorValueTooLong: () => 'A value is too long for one of its fields.',
+ importErrorGeneric: () => 'This row could not be imported. Open the details for the technical report or contact support.',
+};
+
+/** snake_case / camelCase DB column → a human-readable "Title Case" label, self-contained. */
+function toReadableFieldLabel(column) {
+ const normalized = String(column || '').trim();
+ if (!normalized) return 'Field';
+ return normalized
+ .replace(/_/g, ' ')
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
+ .toLowerCase()
+ .replace(/\b\w/g, (ch) => ch.toUpperCase());
+}
+
+/**
+ * Maps a raw backend diagnostic string to a stable, translatable error KIND — an i18n key,
+ * its interpolation params, and whether the outcome is a benign duplicate (a row that
+ * already exists server-side: nothing for the user to fix or retry, so it is reported
+ * separately from a genuine FAILED outcome — the same wording matches ANY entity's unique
+ * index, e.g. "There is already a Business Partner with the same (...). (...) must be
+ * unique.").
+ *
+ * `importEngine.js` is a plain app-shell-core lib and cannot import the functional app's
+ * error code (useEntity.normalizeServerError / backendErrors.js), so the KINDS worth
+ * recognizing are reimplemented here, self-contained. Anything unrecognized falls through
+ * to `importErrorGeneric` — a friendly generic, never the raw dump. Regex gaps are bounded
+ * ({1,200}) rather than unbounded to avoid super-linear backtracking (SonarQube S5852).
+ */
+export function classifyImportError(rawMessage) {
+ const msg = typeof rawMessage === 'string' ? rawMessage : '';
+ const requiredMatch = msg.match(/null value in column\s+"([^"]+)"\s+of relation/i);
+ if (requiredMatch) {
+ return { key: 'importErrorRequiredField', params: { field: toReadableFieldLabel(requiredMatch[1]) }, duplicate: false };
+ }
+ if (/violates\s+not-null\s+constraint/i.test(msg)) {
+ return { key: 'importErrorRequiredGeneric', params: {}, duplicate: false };
+ }
+ if (/duplicate key value violates unique constraint/i.test(msg)) {
+ return { key: 'importErrorDuplicate', params: {}, duplicate: true };
+ }
+ if (
+ /ya existe.{1,200}\(.{1,200}\).{1,200}debe ser único/i.test(msg)
+ || /there is already.{1,200}\(.{1,200}\).{1,200}must be unique/i.test(msg)
+ || /must be unique/i.test(msg)
+ ) {
+ return { key: 'importErrorDuplicateIdentifier', params: {}, duplicate: true };
+ }
+ // Etendo auto-creates a portal AD_User for a contact; the derived username can collide
+ // with an existing user. Its own top-level `{error:{message}}` shape carries no "must be
+ // unique" wording, so it needs its own branch rather than falling into the generic bucket.
+ // `duplicate: false` (unlike the genuine already-exists cases above): the CONTACT is not a
+ // duplicate — only the derived username collides — so the user can fix it (change the
+ // commercial name) and retry. It gets the normal actionable-error treatment (stays in the
+ // queue, counts toward failedCount, retriable inline) rather than being silently skipped.
+ if (/user with the same name already exists/i.test(msg)) {
+ return { key: 'importErrorDuplicateUser', params: {}, duplicate: false };
+ }
+ if (/value too long/i.test(msg)) {
+ return { key: 'importErrorValueTooLong', params: {}, duplicate: false };
+ }
+ return { key: 'importErrorGeneric', params: {}, duplicate: false };
+}
+
+/**
+ * Turns a classification into the user-facing message. With an injected `translate` it
+ * returns the localized string (falling back to the English default if the key is missing —
+ * `translate` returning the key unchanged is the "missing" signal, same guard the app's own
+ * translate helpers use). With no `translate`, the English default. Never the raw backend text.
+ */
+function friendlyImportMessage(classification, translate) {
+ const { key, params } = classification;
+ const fallback = IMPORT_ERROR_FALLBACKS[key](params);
+ if (typeof translate !== 'function') return fallback;
+ const translated = translate(key, params);
+ return translated && translated !== key ? translated : fallback;
}
/**
@@ -46,7 +126,7 @@ function firstValidationMessage(errors) {
* may have already committed server-side, and blindly treating it as a safe
* retry target risks a duplicate create.
*/
-export async function sendRow(operations, { postBatch }) {
+export async function sendRow(operations, { postBatch, translate } = {}) {
let response;
try {
response = await postBatch(operations);
@@ -83,22 +163,28 @@ export async function sendRow(operations, { postBatch }) {
// full trace, one at a time, until the pipeline stabilizes).
const raw = error.raw ?? (error.detail ? JSON.stringify(error.detail, null, 2) : JSON.stringify(response, null, 2));
// error.message is BatchService.java's own generic wrapper ("Operation 'bp' rejected by
- // server") — always the same text regardless of cause, so isDuplicateKeyError's
- // /must be unique/i check against it can never match. The real diagnostic text (e.g.
- // Etendo's own "... must be unique." message) lives one level deeper, at
- // error.detail.error.message, whenever NeoCrudHandler attached the underlying NEO
- // response as `detail` (verified against a real capture of a duplicate-key /batch
- // failure). Prefer that nested message everywhere this result's error surfaces —
- // both for classification and for what the review queue actually shows the user —
- // falling back to the wrapper text only when there's no nested detail to read. A
- // validation-error op (NEO status -4) uses a third shape instead — detail.response.errors
- // — checked second since a plain-failure op never has both.
- const diagnosticMessage =
+ // server") — always the same text regardless of cause, so classification against it can
+ // never match. The real diagnostic text (e.g. Etendo's own "... must be unique." message)
+ // lives one level deeper, at error.detail.error.message, whenever NeoCrudHandler attached
+ // the underlying NEO response as `detail` (verified against a real capture of a
+ // duplicate-key /batch failure). Read that nested message first, falling back to the
+ // wrapper only when there's no nested detail. A validation-error op (NEO status -4) uses
+ // a third shape instead — detail.response.errors — checked second since a plain-failure
+ // op never has both. This is the RAW text used only to CLASSIFY the outcome; it is never
+ // shown to the user directly.
+ const rawDiagnostic =
error.detail?.error?.message
|| firstValidationMessage(error.detail?.response?.errors)
|| error.message;
- const status = isDuplicateKeyError(diagnosticMessage) ? SEND_STATUS.DUPLICATE : SEND_STATUS.FAILED;
- return { status, error: { ...error, message: diagnosticMessage, raw } };
+ const classification = classifyImportError(rawDiagnostic);
+ const status = classification.duplicate ? SEND_STATUS.DUPLICATE : SEND_STATUS.FAILED;
+ // The user-facing message is the classified, friendly (and, when `translate` is injected,
+ // localized) text — NEVER `rawDiagnostic`, which can be an uncontrolled backend leak
+ // (e.g. an unserialized `com.etendoerp.redis.interfaces.CachedSet@55b0cf12`, ETP-4668).
+ // The raw text stays on `error.raw` for the console/telemetry and the system-error
+ // dialog's collapsible report, so nothing diagnostic is lost — it just isn't the bubble.
+ const message = friendlyImportMessage(classification, translate);
+ return { status, error: { ...error, message, raw } };
}
/**
@@ -126,7 +212,7 @@ async function runBoundedPool(items, concurrency, worker, onSettle) {
* `results` — the caller reports `truncatedCount` explicitly rather than
* silently dropping them.
*/
-export async function runImport(rows, { buildRowOperations, postBatch, concurrency = 4, maxRows = 5000, onProgress }) {
+export async function runImport(rows, { buildRowOperations, postBatch, translate, concurrency = 4, maxRows = 5000, onProgress }) {
const attempted = rows.slice(0, maxRows);
const truncatedCount = rows.length - attempted.length;
const results = new Array(attempted.length);
@@ -155,7 +241,7 @@ export async function runImport(rows, { buildRowOperations, postBatch, concurren
} catch (error) {
return { status: SEND_STATUS.FAILED, error, operations: null };
}
- const result = await sendRow(operations, { postBatch });
+ const result = await sendRow(operations, { postBatch, translate });
return { ...result, operations };
},
(result, row, index) => {