diff --git a/.changeset/import-error-localization.md b/.changeset/import-error-localization.md new file mode 100644 index 000000000..e85c86e4d --- /dev/null +++ b/.changeset/import-error-localization.md @@ -0,0 +1,21 @@ +--- +"@object-ui/plugin-grid": patch +"@object-ui/i18n": patch +--- + +fix(grid): localize import result errors (objectstack#3566) + +The import completion screen rendered the raw English server message verbatim — +e.g. `Row 6 (position): position: "装配工" matches more than one +os_tianshun_ehr_position — use a unique value or the record id` — with the field +name twice, an internal object api-name, all in English, while the dry-run panel +already localized the same errors. + +- The result list now runs through the same `formatDryRunError` path (driving + off the structured error `code`, resolving the api-name to its field label, + dropping the duplicated `:` prefix). Threaded the error `code` + through `ImportResult.errors` to make this possible. +- Added code-driven translations for the remaining structured import errors — + `invalid_boolean` / `invalid_number` / `invalid_date` / `invalid_option` / + `required` / `AMBIGUOUS_MATCH` — with Chinese (`zh`) copy in `@object-ui/i18n` + alongside the existing reference errors. diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index e2ba93a61..7527d22e4 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -321,6 +321,12 @@ const zh = { // 服务端结构化导入错误的友好文案 referenceNotFound: '找不到匹配 "{{value}}" 的记录', referenceAmbiguous: '"{{value}}" 匹配到多条记录,请使用唯一值或记录 ID', + invalidBoolean: '"{{value}}" 不是有效的是/否值', + invalidNumber: '"{{value}}" 不是有效的数字', + invalidDate: '"{{value}}" 不是有效的日期', + invalidOption: '"{{value}}" 不在允许的选项范围内', + requiredValue: '此字段为必填项', + matchAmbiguous: '匹配到多条已有记录,请使用唯一值或记录 ID', // 导入选项 / 写入模式 options: '导入选项', writeMode: '当某行匹配到已有记录时', diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index c061faaba..6c4767b23 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -132,9 +132,17 @@ const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.validatePassed': 'All {{ok}} rows are valid.', 'grid.import.validateFailed': '{{ok}} valid, {{errors}} with errors.', 'grid.import.errorRowPrefix': 'Row {{row}}: ', - // Friendly, localized renderings of the server's structured import errors. + // Friendly, localized renderings of the server's structured import errors — + // driven off the error `code` so the raw English server text (with its + // baked-in api-name and internal object name) never reaches the user. 'grid.import.referenceNotFound': 'No matching record for "{{value}}"', 'grid.import.referenceAmbiguous': '"{{value}}" matches more than one record — use a unique value or the record id', + 'grid.import.invalidBoolean': '"{{value}}" is not a valid true/false value', + 'grid.import.invalidNumber': '"{{value}}" is not a valid number', + 'grid.import.invalidDate': '"{{value}}" is not a valid date', + 'grid.import.invalidOption': '"{{value}}" is not one of the allowed options', + 'grid.import.requiredValue': 'This field is required', + 'grid.import.matchAmbiguous': 'Matches more than one existing record — use a unique value or the record id', // Import-job history 'grid.import.history': 'History', 'grid.import.historyBack': 'Back to import', @@ -304,7 +312,7 @@ export interface ImportResult { totalRows: number; importedRows: number; skippedRows: number; - errors: Array<{ row: number; field: string; message: string }>; + errors: Array<{ row: number; field: string; message: string; code?: string }>; /** Rows that created a new record (server-side import). */ createdRows?: number; /** Rows that updated an existing record (server-side import). */ @@ -404,6 +412,18 @@ function formatDryRunError( return { fieldLabel, message: t('grid.import.referenceNotFound', { value: shown }) }; case 'reference_ambiguous': return { fieldLabel, message: t('grid.import.referenceAmbiguous', { value: shown }) }; + case 'invalid_boolean': + return { fieldLabel, message: t('grid.import.invalidBoolean', { value: shown }) }; + case 'invalid_number': + return { fieldLabel, message: t('grid.import.invalidNumber', { value: shown }) }; + case 'invalid_date': + return { fieldLabel, message: t('grid.import.invalidDate', { value: shown }) }; + case 'invalid_option': + return { fieldLabel, message: t('grid.import.invalidOption', { value: shown }) }; + case 'required': + return { fieldLabel, message: t('grid.import.requiredValue') }; + case 'AMBIGUOUS_MATCH': + return { fieldLabel, message: t('grid.import.matchAmbiguous') }; } let message = (r.error ?? r.code ?? '').trim(); // Drop a leading `:` the server prepended, so it isn't shown on top @@ -424,7 +444,7 @@ function formatDryRunError( */ export function isPlausibleEmail(value: string): boolean { if (value.length === 0 || value.length > 254 || /\s/.test(value)) return false; - if (/[^\x00-\x7f]/.test(value)) return false; // ASCII only, like the server + if (/[^\x20-\x7e]/.test(value)) return false; // printable ASCII only, like the server const at = value.indexOf('@'); if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false; const domain = value.slice(at + 1); @@ -624,7 +644,7 @@ function jobResultToImportResult(res: ImportJobResultsInfo): ImportResult { updatedRows: res.updated, errors: (res.results ?? []) .filter((r) => !r.ok) - .map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed' })), + .map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed', code: r.code })), resultsTruncated: res.resultsTruncated, }; } @@ -2005,7 +2025,7 @@ export const ImportWizard: React.FC = ({ updatedRows: res.updated, errors: res.results .filter((r) => !r.ok) - .map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed' })), + .map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed', code: r.code })), serverResult: res, }; setProgress(100); @@ -2380,9 +2400,24 @@ export const ImportWizard: React.FC = ({ {result.errors.length > 0 && ( <>
- {result.errors.slice(0, 10).map((err, i) => ( -

{err.row >= 1 ? `Row ${err.row}${err.field ? ` (${err.field})` : ''}: ` : ''}{err.message}

- ))} + {result.errors.slice(0, 10).map((err, i) => { + // Localize the same way the dry-run panel does (drive off + // `code`, resolve the api-name to its label, drop the raw + // server text for known codes) so the completion screen and + // the pre-check read identically (objectstack#3566). + const { fieldLabel, message } = formatDryRunError( + { field: err.field, error: err.message, code: err.code }, + fieldLabelByName, + dryRunCellValue(err.row, err.field), + t, + ); + return ( +

+ {err.row >= 1 ? t('grid.import.errorRowPrefix', { row: err.row }) : ''} + {fieldLabel ? `${fieldLabel}: ` : ''}{message} +

+ ); + })} {result.errors.length > 10 &&

{t('grid.import.moreErrors', { count: result.errors.length - 10 })}

}
{result.errors.some((e) => e.row >= 1) && ( diff --git a/packages/plugin-grid/src/importAsyncPath.test.ts b/packages/plugin-grid/src/importAsyncPath.test.ts index a27f42a6a..8c6862c91 100644 --- a/packages/plugin-grid/src/importAsyncPath.test.ts +++ b/packages/plugin-grid/src/importAsyncPath.test.ts @@ -95,9 +95,9 @@ describe('jobResultToImportResult', () => { expect(r.resultsTruncated).toBe(false); }); - it('projects only failed rows into the errors list, preferring error over code', () => { + it('projects only failed rows into the errors list, preferring error over code (and carrying the code for localization)', () => { const r = jobResultToImportResult(base); - expect(r.errors).toEqual([{ row: 5, field: 'amount', message: 'not a number' }]); + expect(r.errors).toEqual([{ row: 5, field: 'amount', message: 'not a number', code: 'COERCE' }]); }); it('carries the resultsTruncated flag through when the server capped the report', () => { diff --git a/packages/plugin-grid/src/importDryRun.test.ts b/packages/plugin-grid/src/importDryRun.test.ts index fb6a33488..63940a2b5 100644 --- a/packages/plugin-grid/src/importDryRun.test.ts +++ b/packages/plugin-grid/src/importDryRun.test.ts @@ -79,6 +79,10 @@ describe('formatDryRunError', () => { ({ 'grid.import.referenceNotFound': `No matching record for "${vars?.value}"`, 'grid.import.referenceAmbiguous': `"${vars?.value}" matches more than one record`, + 'grid.import.invalidNumber': `"${vars?.value}" is not a valid number`, + 'grid.import.invalidOption': `"${vars?.value}" is not one of the allowed options`, + 'grid.import.requiredValue': 'This field is required', + 'grid.import.matchAmbiguous': 'Matches more than one existing record — use a unique value or the record id', } as Record)[key] ?? key; it('resolves the field api-name to its label', () => { @@ -116,6 +120,41 @@ describe('formatDryRunError', () => { expect(message).toBe('"导管架" matches more than one record'); }); + it('maps a coercion code (invalid_number) off the code, not the raw text', () => { + const { message } = formatDryRunError( + { field: 'product', code: 'invalid_number', error: 'product: "abc" is not a number' }, + labels, 'abc', t, + ); + expect(message).toBe('"abc" is not a valid number'); + expect(message).not.toContain('product:'); + }); + + it('maps invalid_option through its key', () => { + const { message } = formatDryRunError( + { field: 'product', code: 'invalid_option', error: 'product: "x" is not a known option' }, + labels, 'x', t, + ); + expect(message).toBe('"x" is not one of the allowed options'); + }); + + it('maps a required-field miss to a value-free message', () => { + const { fieldLabel, message } = formatDryRunError( + { field: 'product', code: 'required', error: 'product is required' }, + labels, undefined, t, + ); + expect(fieldLabel).toBe('产品'); + expect(message).toBe('This field is required'); + }); + + it('maps AMBIGUOUS_MATCH (upsert match key) without leaking the object api-name', () => { + const { message } = formatDryRunError( + { field: '', code: 'AMBIGUOUS_MATCH', error: 'matchFields matched more than one os_x_position record' }, + labels, undefined, t, + ); + expect(message).toBe('Matches more than one existing record — use a unique value or the record id'); + expect(message).not.toContain('os_x_position'); + }); + it('strips a duplicated api-name prefix for codes it does not recognize', () => { const { fieldLabel, message } = formatDryRunError( { field: 'product', code: 'some_other_error', error: 'product: value is out of range' },