Skip to content

Commit 09c6a17

Browse files
baozhoutaoclaude
andauthored
fix(grid): localize import result errors (objectstack#3566) (#2861)
The import completion screen rendered the raw English server message verbatim (field name twice, internal object api-name, all English) while the dry-run panel already localized the same errors. Route the result list through the same formatDryRunError path and thread the error `code` through ImportResult.errors. - Code-driven translations for the remaining structured import errors (invalid_boolean/number/date/option, required, AMBIGUOUS_MATCH) + zh copy in @object-ui/i18n. - isPlausibleEmail: spell the ASCII range with printable bounds (0x20-0x7e) so the regex carries no control character (eslint no-control-regex). - Tests for the new code mappings; updated the ImportResult.errors shape assertion. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c6cfdf1 commit 09c6a17

5 files changed

Lines changed: 111 additions & 10 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@object-ui/plugin-grid": patch
3+
"@object-ui/i18n": patch
4+
---
5+
6+
fix(grid): localize import result errors (objectstack#3566)
7+
8+
The import completion screen rendered the raw English server message verbatim —
9+
e.g. `Row 6 (position): position: "装配工" matches more than one
10+
os_tianshun_ehr_position — use a unique value or the record id` — with the field
11+
name twice, an internal object api-name, all in English, while the dry-run panel
12+
already localized the same errors.
13+
14+
- The result list now runs through the same `formatDryRunError` path (driving
15+
off the structured error `code`, resolving the api-name to its field label,
16+
dropping the duplicated `<api-name>:` prefix). Threaded the error `code`
17+
through `ImportResult.errors` to make this possible.
18+
- Added code-driven translations for the remaining structured import errors —
19+
`invalid_boolean` / `invalid_number` / `invalid_date` / `invalid_option` /
20+
`required` / `AMBIGUOUS_MATCH` — with Chinese (`zh`) copy in `@object-ui/i18n`
21+
alongside the existing reference errors.

packages/i18n/src/locales/zh.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,12 @@ const zh = {
321321
// 服务端结构化导入错误的友好文案
322322
referenceNotFound: '找不到匹配 "{{value}}" 的记录',
323323
referenceAmbiguous: '"{{value}}" 匹配到多条记录,请使用唯一值或记录 ID',
324+
invalidBoolean: '"{{value}}" 不是有效的是/否值',
325+
invalidNumber: '"{{value}}" 不是有效的数字',
326+
invalidDate: '"{{value}}" 不是有效的日期',
327+
invalidOption: '"{{value}}" 不在允许的选项范围内',
328+
requiredValue: '此字段为必填项',
329+
matchAmbiguous: '匹配到多条已有记录,请使用唯一值或记录 ID',
324330
// 导入选项 / 写入模式
325331
options: '导入选项',
326332
writeMode: '当某行匹配到已有记录时',

packages/plugin-grid/src/ImportWizard.tsx

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,17 @@ const IMPORT_DEFAULT_TRANSLATIONS: Record<string, string> = {
132132
'grid.import.validatePassed': 'All {{ok}} rows are valid.',
133133
'grid.import.validateFailed': '{{ok}} valid, {{errors}} with errors.',
134134
'grid.import.errorRowPrefix': 'Row {{row}}: ',
135-
// Friendly, localized renderings of the server's structured import errors.
135+
// Friendly, localized renderings of the server's structured import errors —
136+
// driven off the error `code` so the raw English server text (with its
137+
// baked-in api-name and internal object name) never reaches the user.
136138
'grid.import.referenceNotFound': 'No matching record for "{{value}}"',
137139
'grid.import.referenceAmbiguous': '"{{value}}" matches more than one record — use a unique value or the record id',
140+
'grid.import.invalidBoolean': '"{{value}}" is not a valid true/false value',
141+
'grid.import.invalidNumber': '"{{value}}" is not a valid number',
142+
'grid.import.invalidDate': '"{{value}}" is not a valid date',
143+
'grid.import.invalidOption': '"{{value}}" is not one of the allowed options',
144+
'grid.import.requiredValue': 'This field is required',
145+
'grid.import.matchAmbiguous': 'Matches more than one existing record — use a unique value or the record id',
138146
// Import-job history
139147
'grid.import.history': 'History',
140148
'grid.import.historyBack': 'Back to import',
@@ -304,7 +312,7 @@ export interface ImportResult {
304312
totalRows: number;
305313
importedRows: number;
306314
skippedRows: number;
307-
errors: Array<{ row: number; field: string; message: string }>;
315+
errors: Array<{ row: number; field: string; message: string; code?: string }>;
308316
/** Rows that created a new record (server-side import). */
309317
createdRows?: number;
310318
/** Rows that updated an existing record (server-side import). */
@@ -404,6 +412,18 @@ function formatDryRunError(
404412
return { fieldLabel, message: t('grid.import.referenceNotFound', { value: shown }) };
405413
case 'reference_ambiguous':
406414
return { fieldLabel, message: t('grid.import.referenceAmbiguous', { value: shown }) };
415+
case 'invalid_boolean':
416+
return { fieldLabel, message: t('grid.import.invalidBoolean', { value: shown }) };
417+
case 'invalid_number':
418+
return { fieldLabel, message: t('grid.import.invalidNumber', { value: shown }) };
419+
case 'invalid_date':
420+
return { fieldLabel, message: t('grid.import.invalidDate', { value: shown }) };
421+
case 'invalid_option':
422+
return { fieldLabel, message: t('grid.import.invalidOption', { value: shown }) };
423+
case 'required':
424+
return { fieldLabel, message: t('grid.import.requiredValue') };
425+
case 'AMBIGUOUS_MATCH':
426+
return { fieldLabel, message: t('grid.import.matchAmbiguous') };
407427
}
408428
let message = (r.error ?? r.code ?? '').trim();
409429
// Drop a leading `<api-name>:` the server prepended, so it isn't shown on top
@@ -424,7 +444,7 @@ function formatDryRunError(
424444
*/
425445
export function isPlausibleEmail(value: string): boolean {
426446
if (value.length === 0 || value.length > 254 || /\s/.test(value)) return false;
427-
if (/[^\x00-\x7f]/.test(value)) return false; // ASCII only, like the server
447+
if (/[^\x20-\x7e]/.test(value)) return false; // printable ASCII only, like the server
428448
const at = value.indexOf('@');
429449
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false;
430450
const domain = value.slice(at + 1);
@@ -624,7 +644,7 @@ function jobResultToImportResult(res: ImportJobResultsInfo): ImportResult {
624644
updatedRows: res.updated,
625645
errors: (res.results ?? [])
626646
.filter((r) => !r.ok)
627-
.map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed' })),
647+
.map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed', code: r.code })),
628648
resultsTruncated: res.resultsTruncated,
629649
};
630650
}
@@ -2005,7 +2025,7 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
20052025
updatedRows: res.updated,
20062026
errors: res.results
20072027
.filter((r) => !r.ok)
2008-
.map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed' })),
2028+
.map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed', code: r.code })),
20092029
serverResult: res,
20102030
};
20112031
setProgress(100);
@@ -2380,9 +2400,24 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
23802400
{result.errors.length > 0 && (
23812401
<>
23822402
<div className="max-h-32 w-full overflow-auto rounded border p-2 text-xs">
2383-
{result.errors.slice(0, 10).map((err, i) => (
2384-
<p key={i} className="text-destructive">{err.row >= 1 ? `Row ${err.row}${err.field ? ` (${err.field})` : ''}: ` : ''}{err.message}</p>
2385-
))}
2403+
{result.errors.slice(0, 10).map((err, i) => {
2404+
// Localize the same way the dry-run panel does (drive off
2405+
// `code`, resolve the api-name to its label, drop the raw
2406+
// server text for known codes) so the completion screen and
2407+
// the pre-check read identically (objectstack#3566).
2408+
const { fieldLabel, message } = formatDryRunError(
2409+
{ field: err.field, error: err.message, code: err.code },
2410+
fieldLabelByName,
2411+
dryRunCellValue(err.row, err.field),
2412+
t,
2413+
);
2414+
return (
2415+
<p key={i} className="text-destructive">
2416+
{err.row >= 1 ? t('grid.import.errorRowPrefix', { row: err.row }) : ''}
2417+
{fieldLabel ? `${fieldLabel}: ` : ''}{message}
2418+
</p>
2419+
);
2420+
})}
23862421
{result.errors.length > 10 && <p className="text-muted-foreground">{t('grid.import.moreErrors', { count: result.errors.length - 10 })}</p>}
23872422
</div>
23882423
{result.errors.some((e) => e.row >= 1) && (

packages/plugin-grid/src/importAsyncPath.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ describe('jobResultToImportResult', () => {
9595
expect(r.resultsTruncated).toBe(false);
9696
});
9797

98-
it('projects only failed rows into the errors list, preferring error over code', () => {
98+
it('projects only failed rows into the errors list, preferring error over code (and carrying the code for localization)', () => {
9999
const r = jobResultToImportResult(base);
100-
expect(r.errors).toEqual([{ row: 5, field: 'amount', message: 'not a number' }]);
100+
expect(r.errors).toEqual([{ row: 5, field: 'amount', message: 'not a number', code: 'COERCE' }]);
101101
});
102102

103103
it('carries the resultsTruncated flag through when the server capped the report', () => {

packages/plugin-grid/src/importDryRun.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ describe('formatDryRunError', () => {
7979
({
8080
'grid.import.referenceNotFound': `No matching record for "${vars?.value}"`,
8181
'grid.import.referenceAmbiguous': `"${vars?.value}" matches more than one record`,
82+
'grid.import.invalidNumber': `"${vars?.value}" is not a valid number`,
83+
'grid.import.invalidOption': `"${vars?.value}" is not one of the allowed options`,
84+
'grid.import.requiredValue': 'This field is required',
85+
'grid.import.matchAmbiguous': 'Matches more than one existing record — use a unique value or the record id',
8286
} as Record<string, string>)[key] ?? key;
8387

8488
it('resolves the field api-name to its label', () => {
@@ -116,6 +120,41 @@ describe('formatDryRunError', () => {
116120
expect(message).toBe('"导管架" matches more than one record');
117121
});
118122

123+
it('maps a coercion code (invalid_number) off the code, not the raw text', () => {
124+
const { message } = formatDryRunError(
125+
{ field: 'product', code: 'invalid_number', error: 'product: "abc" is not a number' },
126+
labels, 'abc', t,
127+
);
128+
expect(message).toBe('"abc" is not a valid number');
129+
expect(message).not.toContain('product:');
130+
});
131+
132+
it('maps invalid_option through its key', () => {
133+
const { message } = formatDryRunError(
134+
{ field: 'product', code: 'invalid_option', error: 'product: "x" is not a known option' },
135+
labels, 'x', t,
136+
);
137+
expect(message).toBe('"x" is not one of the allowed options');
138+
});
139+
140+
it('maps a required-field miss to a value-free message', () => {
141+
const { fieldLabel, message } = formatDryRunError(
142+
{ field: 'product', code: 'required', error: 'product is required' },
143+
labels, undefined, t,
144+
);
145+
expect(fieldLabel).toBe('产品');
146+
expect(message).toBe('This field is required');
147+
});
148+
149+
it('maps AMBIGUOUS_MATCH (upsert match key) without leaking the object api-name', () => {
150+
const { message } = formatDryRunError(
151+
{ field: '', code: 'AMBIGUOUS_MATCH', error: 'matchFields matched more than one os_x_position record' },
152+
labels, undefined, t,
153+
);
154+
expect(message).toBe('Matches more than one existing record — use a unique value or the record id');
155+
expect(message).not.toContain('os_x_position');
156+
});
157+
119158
it('strips a duplicated api-name prefix for codes it does not recognize', () => {
120159
const { fieldLabel, message } = formatDryRunError(
121160
{ field: 'product', code: 'some_other_error', error: 'product: value is out of range' },

0 commit comments

Comments
 (0)