Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c953465
Feature ETP-4559: Neutralize CSV formula injection in client CSV buil…
sebastianbarrozo Jul 23, 2026
fe3d7b6
Feature ETP-4657: Fix semantic color token drift from ETP-4554
sebastianbarrozo Jul 23, 2026
0fd7bc7
Merge remote-tracking branch 'origin/epic/ETP-3504' into feature/ETP-…
sebastianbarrozo Jul 24, 2026
6b22c93
Feature ETP-4657: Fix data-testid codemod formatting in Table
sebastianbarrozo Jul 24, 2026
d7429d4
Feature ETP-4669: i18n import flow labels + friendly backend errors
sebastianbarrozo Jul 24, 2026
818c98d
Feature ETP-4669: Add import label-forwarding + error i18n unit tests
sebastianbarrozo Jul 24, 2026
90adb27
Feature ETP-4669: Apply data-testid codemod to table.jsx (pre-push gate)
sebastianbarrozo Jul 24, 2026
3a6cfa3
Merge branch 'feature/ETP-4669' into feature/ETP-4657
sebastianbarrozo Jul 24, 2026
083ad97
Feature ETP-4669: Classify duplicate-user error with friendly message
sebastianbarrozo Jul 24, 2026
712cc40
Feature ETP-4669: Treat duplicate-user error as actionable, not skipped
sebastianbarrozo Jul 24, 2026
29a320c
Merge branch 'feature/ETP-4669' into feature/ETP-4657
sebastianbarrozo Jul 24, 2026
3674d68
Feature ETP-4669: Add duplicate-user classifier regression test
sebastianbarrozo Jul 24, 2026
eb2121d
Merge remote-tracking branch 'origin/epic/ETP-3504' into feature/ETP-…
sebastianbarrozo Jul 27, 2026
1947914
Merge branch 'feature/ETP-4669' into feature/ETP-4657
sebastianbarrozo Jul 27, 2026
5fc52cf
Feature ETP-4657: Exclude locale JSON dictionaries from duplicated-bl…
sebastianbarrozo Jul 27, 2026
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
11 changes: 10 additions & 1 deletion cli/src/pr-review.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ function isGeneratedDependencyManifest(path) {
return /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(path);
}

function isLocaleJsonFile(path) {
// Locale JSON dictionaries are DATA (parallel key -> translation maps), not
// application logic: translated strings legitimately repeat across locale
// dictionaries (e.g. es_AR and es_ES sharing the same neutral phrasing) —
// DRY/extract-shared-logic rules apply to logic, not to data.
return /\/locales\/[^/]+\.json$/.test(path);
}

function normalizeDuplicateLine(line) {
const trimmed = line.trim();
if (!trimmed) return '';
Expand Down Expand Up @@ -171,6 +179,7 @@ export function detectDuplicatedBlocks(diffText, { minLines = DUPLICATE_MIN_LINE
// rules do not apply, same as the artifacts/ skip above.
if (run.path.startsWith('cli/cache/')) continue;
if (isGeneratedDependencyManifest(run.path)) continue;
if (isLocaleJsonFile(run.path)) continue;
const normalizedLines = run.lines
.map((entry) => ({ ...entry, normalized: normalizeDuplicateLine(entry.text) }))
.filter((entry) => entry.normalized);
Expand Down Expand Up @@ -313,7 +322,7 @@ function isExemptFromSizeGate(path) {
return true;
}
// Locale JSON files grow predictably as the app is translated — skip them.
if (/\/locales\/[^/]+\.json$/.test(path)) {
if (isLocaleJsonFile(path)) {
return true;
}
// Generated contract snapshots are intentionally verbose and are checked
Expand Down
47 changes: 47 additions & 0 deletions cli/test/pr-review.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,53 @@ describe('detectDuplicatedBlocks', () => {
['tools/app-shell/src/foo.js', 'tools/app-shell/src/bar.js'],
);
});

it('ignores duplicated blocks in locale JSON dictionaries', () => {
const block = [
'+ "csvImport": "Importar",',
'+ "csvImportEditCorrespondence": "Editar correspondencia",',
'+ "csvImportReviewQueue": "Cola de revisión",',
'+ "csvImportApplyToAll": "Aplicar a todos",',
'+ "csvImportRevalidate": "Revalidar",',
'+ "csvImportDiscardRow": "Descartar fila"',
].join('\n');

const diff = [
makeDiff('tools/app-shell/src/locales/es_AR.json', block),
makeDiff('tools/app-shell/src/locales/es_ES.json', block),
].join('\n');

assert.deepEqual(detectDuplicatedBlocks(diff), []);
});

it('still flags the same duplicated block when it lands in real source (locale skip is path-scoped, not a blanket disable)', () => {
const block = [
'+ "csvImport": "Importar",',
'+ "csvImportEditCorrespondence": "Editar correspondencia",',
'+ "csvImportReviewQueue": "Cola de revisión",',
'+ "csvImportApplyToAll": "Aplicar a todos",',
'+ "csvImportRevalidate": "Revalidar",',
'+ "csvImportDiscardRow": "Descartar fila"',
].join('\n');

// The locale JSON copy is silently dropped; the two real source copies must still
// collide. This proves the skip only removes locales/ runs from the comparison,
// rather than disabling duplication detection for the whole diff.
const diff = [
makeDiff('tools/app-shell/src/locales/es_AR.json', block),
makeDiff('tools/app-shell/src/foo.js', block),
makeDiff('tools/app-shell/src/bar.js', block),
].join('\n');

const findings = detectDuplicatedBlocks(diff);
assert.equal(findings.length, 1);
assert.equal(findings[0].code, 'DUPLICATED_BLOCK');
assert.equal(findings[0].severity, 'blocker');
assert.deepEqual(
findings[0].locations.map((location) => location.path),
['tools/app-shell/src/foo.js', 'tools/app-shell/src/bar.js'],
);
});
});

describe('analyzeChangedFiles', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"!src/**/__tests__/**"
],
"scripts": {
"test": "node --test test/*.test.js src/auth/__tests__/*.test.js src/i18n/__tests__/*.test.js src/runtime/__tests__/*.test.js src/theme/__tests__/*.test.js src/components/ui/__tests__/*.test.js src/lib/__tests__/*.test.js src/lib/validation/__tests__/*.test.js src/lib/import/__tests__/*.test.js",
"test": "node --test test/*.test.js src/auth/__tests__/*.test.js src/i18n/__tests__/*.test.js src/runtime/__tests__/*.test.js src/theme/__tests__/*.test.js src/components/ui/__tests__/*.test.js src/lib/__tests__/*.test.js src/lib/validation/__tests__/*.test.js src/lib/import/__tests__/*.test.js src/lib/csv/__tests__/*.test.js",
"test:vitest": "vitest run",
"test:consumer": "node scripts/package-consumer-smoke.mjs"
},
Expand Down
66 changes: 54 additions & 12 deletions packages/app-shell-core/src/components/import/ImportDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,38 @@ import { buildOperations } from '../../lib/import/buildOperations.js';
import { runImport, sendRow, SEND_STATUS } from '../../lib/import/importEngine.js';
import { buildTemplateCsv } from '../../lib/import/buildTemplateCsv.js';

const DEFAULT_LABELS = { title: 'Import', revalidating: 'Revalidating rows…', downloadTemplate: 'Download CSV template' };
// Root-level labels for ImportDialog's own chrome. `importButton` is a function of the
// valid-row count, mirroring the (n) => string labels the confirm step already uses, so the
// whole flow's button text is translatable rather than the hardcoded `Import ${n}` it was.
const DEFAULT_LABELS = { title: 'Import', revalidating: 'Revalidating rows…', downloadTemplate: 'Download CSV template', importButton: (n) => `Import ${n}` };

/**
* Shape of the optional `labels` prop — a NESTED object: root-level keys for this dialog's
* own chrome, plus one sub-slice per child component, each matching that child's own
* DEFAULT_LABELS. Every level is optional; any omitted key/slice falls back to the child's
* hardcoded English DEFAULT_LABELS. The functional app (etendo_schema_forge's ListView)
* builds this object from its useUI() dictionary and MUST match this shape exactly.
*
* {
* title, revalidating, downloadTemplate, importButton: (n) => string, // this dialog
* dropzone: { dropHere, dropHint }, // ImportDropzone
* progress: { title, subtitle }, // ImportProgressStep
* mapping: { notImported, mappedSummary, editMatch, editTitle, save, cancel }, // ImportColumnMapping
* confirm: { title, willImport: (n) => string, willSkip: (n) => string, cancel, confirm }, // ImportConfirmStep
* fileError: { title, cancel, retry }, // ImportFileErrorDialog
* reviewQueue: { filterAll, filterOk, filterError, skip, skipped, unskip, downloadErrors,
* status, statusOk, statusError, fieldErrorsTooltip, bulkApplyTitle,
* bulkApplyDescription, bulkApplyOnlyThis, bulkApplyAll, retry }, // ImportReviewQueue
* // NB: `retry` feeds ImportReviewQueue's separate `retryLabel` prop, not its DEFAULT_LABELS
* systemError: { title, subtitle, copy, copied, copyFailed, close, showReport, hideReport,
* rowData, requestSent, serverResponse }, // ImportSystemErrorDialog
* }
*
* Templated strings (mappedSummary's {mapped}/{total}, fieldErrorsTooltip's {fields},
* bulkApplyDescription's {count}/{raw}/{value}) keep their {placeholders} — each child fills
* them at render time. `translate` is a separate, plain (key, params) => string function
* (e.g. useUI's `ui`) injected into the send pipeline so backend errors get localized too.
*/

const STEP = { DROPZONE: 'dropzone', MAPPING: 'mapping', CONFIRM: 'confirm', SENDING: 'sending', FILE_ERROR: 'fileError', RESULT: 'result' };

Expand All @@ -40,7 +71,7 @@ function renameRowKeys(row, mapping) {
return renamed;
}

export function ImportDialog({ open, onOpenChange, config, token, postBatch, simSearchFn, onImported, labels }) {
export function ImportDialog({ open, onOpenChange, config, token, postBatch, simSearchFn, onImported, labels, translate }) {
const text = { ...DEFAULT_LABELS, ...labels };
const [step, setStep] = useState(STEP.DROPZONE);
const [fileErrorMessage, setFileErrorMessage] = useState(null);
Expand Down Expand Up @@ -98,13 +129,17 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
// which made `simSearch`'s own guard clause short-circuit to "no match" for every row
// instead of actually querying — the descriptor then threw "country could not be
// resolved" for every address-bearing row (confirmed via a real browser run).
// `translate` is threaded into the descriptor config too — a composite descriptor (e.g.
// Contacts) throws its own row-level errors (an unresolved country FK) and needs the app's
// translate fn to localize them, exactly like the send pipeline does for backend errors.
const operationsConfig = useMemo(() => ({
spec: config.spec,
entity: config.entity,
descriptorName: config.descriptor,
targets: config.fields.map((f) => f.target),
token,
}), [config.spec, config.entity, config.descriptor, config.fields, token]);
translate,
}), [config.spec, config.entity, config.descriptor, config.fields, token, translate]);

// The real config shape (decisions.json → window.import, verified against
// artifacts/contacts/decisions.json) is `dedupe: { scope, key: string[] }`, not a flat
Expand Down Expand Up @@ -240,6 +275,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
({ results } = await runImport(toSend.map((e) => e.row), {
buildRowOperations: (row) => buildOperations(row, operationsConfig),
postBatch,
translate,
concurrency: config.concurrency,
maxRows: config.maxRows,
onProgress: (completed, total) => setProgress(Math.round((completed / total) * 100)),
Expand All @@ -251,7 +287,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
}
const okCount = results.filter((r) => r.status === 'ok').length;
// A DUPLICATE result (the row's record already exists server-side, a unique-constraint
// rejection — see importEngine.js's isDuplicateKeyError) is not an actionable failure:
// rejection — see importEngine.js's classifyImportError) is not an actionable failure:
// retrying would only repeat the identical rejection, and there's nothing for the user
// to fix. Reported as a skipped entry (same treatment the pre-send in-file dedupe
// already uses — greyed out, no retry/skip buttons, per ImportReviewQueue) rather than
Expand Down Expand Up @@ -289,12 +325,12 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
onImported({ okCount, failedCount: trueFailures.length });
if (okCount > 0) toast.success(`${okCount} records imported successfully`);
if (duplicateResults.length > 0) toast.info(`${duplicateResults.length} row(s) skipped — already exist`);
}, [entries, operationsConfig, config.concurrency, config.maxRows, postBatch, onImported]);
}, [entries, operationsConfig, config.concurrency, config.maxRows, postBatch, onImported, translate]);

const handleRetryEntryPostSend = useCallback(async (index) => {
const entry = entries[index];
const operations = await buildOperations(entry.row, operationsConfig);
const result = await sendRow(operations, { postBatch });
const result = await sendRow(operations, { postBatch, translate });
setEntries((prev) => {
const next = [...prev];
if (result.status === 'ok') {
Expand All @@ -311,7 +347,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
if (result.status !== 'ok' && result.status !== SEND_STATUS.DUPLICATE) {
setSystemError({ message: result.error?.message || 'Unknown error', raw: result.error?.raw, row: entry.row, operations });
}
}, [entries, operationsConfig, postBatch]);
}, [entries, operationsConfig, postBatch, translate]);

const handleRetryFile = useCallback(() => {
setFileErrorMessage(null);
Expand All @@ -328,7 +364,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim

{step === STEP.DROPZONE && (
<div className="flex flex-col gap-2">
<ImportDropzone onFileSelected={handleFileSelected} data-testid="ImportDropzone__38a6c3" />
<ImportDropzone onFileSelected={handleFileSelected} labels={labels?.dropzone} data-testid="ImportDropzone__38a6c3" />
<button
type="button"
className="self-center text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
Expand All @@ -345,6 +381,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
message={fileErrorMessage}
onCancel={() => onOpenChange(false)}
onRetry={handleRetryFile}
labels={labels?.fileError}
data-testid="ImportFileErrorDialog__38a6c3" />
)}

Expand All @@ -355,6 +392,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
importFields={config.fields}
mapping={mapping}
onApplyMapping={handleApplyMapping}
labels={labels?.mapping}
data-testid="ImportColumnMapping__38a6c3" />
<div className="relative flex min-h-0 flex-1 flex-col">
{isRevalidating && (
Expand All @@ -376,6 +414,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
onUnskipEntry={handleUnskipEntry}
onApplyFkValue={handleApplyFkValue}
onDownloadErrors={() => downloadCsv(buildErrorsCsv(entries, headers, mapping), 'import-errors.csv')}
labels={labels?.reviewQueue}
simSearchFn={simSearchFn}
token={token}
data-testid="ImportReviewQueue__38a6c3" />
Expand All @@ -387,7 +426,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
disabled={validCount === 0}
data-testid="ImportDialog__importButton"
>
{`Import ${validCount}`}
{text.importButton(validCount)}
</Button>
</div>
</div>
Expand All @@ -399,10 +438,11 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
skipCount={skipCount}
onCancel={() => setStep(STEP.MAPPING)}
onConfirm={handleSend}
labels={labels?.confirm}
data-testid="ImportConfirmStep__38a6c3" />
)}

{step === STEP.SENDING && <ImportProgressStep percent={progress} data-testid="ImportProgressStep__38a6c3" />}
{step === STEP.SENDING && <ImportProgressStep percent={progress} labels={labels?.progress} data-testid="ImportProgressStep__38a6c3" />}

{step === STEP.RESULT && (
<div className="flex min-h-0 max-h-[70vh] min-w-0 flex-col gap-4">
Expand All @@ -418,7 +458,8 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
onUnskipEntry={handleUnskipEntry}
onApplyFkValue={handleApplyFkValue}
onDownloadErrors={() => downloadCsv(buildErrorsCsv(entries, headers, mapping), 'import-errors.csv')}
retryLabel="Retry"
retryLabel={labels?.reviewQueue?.retry ?? 'Retry'}
labels={labels?.reviewQueue}
simSearchFn={simSearchFn}
token={token}
data-testid="ImportReviewQueue__38a6c3" />
Expand All @@ -430,7 +471,7 @@ export function ImportDialog({ open, onOpenChange, config, token, postBatch, sim
disabled={validCount === 0}
data-testid="ImportDialog__importButton"
>
{`Import ${validCount}`}
{text.importButton(validCount)}
</Button>
</div>
</div>
Expand All @@ -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" />
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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');
}
Expand Down
Loading
Loading