Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .changeset/import-error-localization.md
Original file line number Diff line number Diff line change
@@ -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 `<api-name>:` 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.
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,12 @@ const zh = {
// 服务端结构化导入错误的友好文案
referenceNotFound: '找不到匹配 "{{value}}" 的记录',
referenceAmbiguous: '"{{value}}" 匹配到多条记录,请使用唯一值或记录 ID',
invalidBoolean: '"{{value}}" 不是有效的是/否值',
invalidNumber: '"{{value}}" 不是有效的数字',
invalidDate: '"{{value}}" 不是有效的日期',
invalidOption: '"{{value}}" 不在允许的选项范围内',
requiredValue: '此字段为必填项',
matchAmbiguous: '匹配到多条已有记录,请使用唯一值或记录 ID',
// 导入选项 / 写入模式
options: '导入选项',
writeMode: '当某行匹配到已有记录时',
Expand Down
51 changes: 43 additions & 8 deletions packages/plugin-grid/src/ImportWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,17 @@ const IMPORT_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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',
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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 `<api-name>:` the server prepended, so it isn't shown on top
Expand All @@ -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);
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -2005,7 +2025,7 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
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);
Expand Down Expand Up @@ -2380,9 +2400,24 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
{result.errors.length > 0 && (
<>
<div className="max-h-32 w-full overflow-auto rounded border p-2 text-xs">
{result.errors.slice(0, 10).map((err, i) => (
<p key={i} className="text-destructive">{err.row >= 1 ? `Row ${err.row}${err.field ? ` (${err.field})` : ''}: ` : ''}{err.message}</p>
))}
{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 (
<p key={i} className="text-destructive">
{err.row >= 1 ? t('grid.import.errorRowPrefix', { row: err.row }) : ''}
{fieldLabel ? `${fieldLabel}: ` : ''}{message}
</p>
);
})}
{result.errors.length > 10 && <p className="text-muted-foreground">{t('grid.import.moreErrors', { count: result.errors.length - 10 })}</p>}
</div>
{result.errors.some((e) => e.row >= 1) && (
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-grid/src/importAsyncPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
39 changes: 39 additions & 0 deletions packages/plugin-grid/src/importDryRun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)[key] ?? key;

it('resolves the field api-name to its label', () => {
Expand Down Expand Up @@ -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' },
Expand Down
Loading