Skip to content

Commit f824056

Browse files
authored
fix(plugin-grid): block legacy import fallback for relation columns (#2164)
When the data source can't reach the server /import route (old client/server), ImportWizard silently fell back to per-row create, storing raw cell text verbatim — for lookup/master_detail/user/ reference/tree columns that writes display text where a record ID belongs (observed in production: relation fields holding plain text). The legacy fallback now refuses to run when any mapped column targets a relation-type field, surfacing a clear error (i18n: embedded fallback + en + zh) instead of corrupting data. Mappings without relation columns keep working as before. Fixes #2163
1 parent d006128 commit f824056

5 files changed

Lines changed: 161 additions & 4 deletions

File tree

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ const en = {
237237
requiredMark: '*',
238238
required: 'Required',
239239
invalidType: 'Invalid {{type}}',
240+
legacyReferenceBlocked: 'Import blocked: {{fields}} are relation fields that need the server import route to resolve names into record IDs, and this connection doesn\u2019t support it. Importing them as plain text would corrupt the data. Upgrade the backend/client, or unmap these columns and import them separately.',
240241
},
241242
},
242243
calendar: {

packages/i18n/src/locales/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ const zh = {
237237
requiredMark: '*',
238238
required: '必填',
239239
invalidType: '{{type}} 格式无效',
240+
legacyReferenceBlocked: '导入已阻止:{{fields}} 是关联字段,需要服务端导入接口把名称解析为记录 ID,而当前连接不支持。按纯文本导入会写坏数据。请升级后端/客户端,或取消这些列的映射后再导入。',
240241
// 自动匹配(Airtable 风格)
241242
autoMatched: '自动匹配',
242243
autoMatchedSummary: '已自动匹配 {{count}} 列 — 请在下方检查并调整。',

packages/plugin-grid/src/ImportWizard.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ const IMPORT_DEFAULT_TRANSLATIONS: Record<string, string> = {
149149
'grid.import.importingProgress': 'Importing…',
150150
'grid.import.required': 'Required',
151151
'grid.import.invalidType': 'Invalid {{type}}',
152+
'grid.import.legacyReferenceBlocked': 'Import blocked: {{fields}} are relation fields that need the server import route to resolve names into record IDs, and this connection doesn’t support it. Importing them as plain text would corrupt the data. Upgrade the backend/client, or unmap these columns and import them separately.',
152153
};
153154

154155
/** Apply `{{var}}` interpolation to a translation template. */
@@ -191,6 +192,7 @@ export const __testables = {
191192
get saveTemplates() { return saveTemplates; },
192193
get autoMapColumns() { return autoMapColumns; },
193194
get isUnsupportedImport() { return isUnsupportedImport; },
195+
get mappedReferenceFields() { return mappedReferenceFields; },
194196
get isUnsupportedImportJob() { return isUnsupportedImportJob; },
195197
get jobResultToImportResult() { return jobResultToImportResult; },
196198
get buildFailedRowsCsv() { return buildFailedRowsCsv; },
@@ -293,6 +295,22 @@ const BOOLEAN_IMPORT_TOKENS = new Set([
293295
'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×',
294296
]);
295297

298+
/** Field types the server resolves from display text to record IDs during
299+
* `/import` (kept in step with the server's import-coerce REFERENCE_TYPES).
300+
* The legacy per-row create fallback has no resolution step — raw cell text
301+
* would be stored verbatim into relation fields — so the fallback must refuse
302+
* to run when any mapped column targets one of these types. */
303+
const REFERENCE_IMPORT_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);
304+
305+
/** Mapped fields whose type the legacy fallback cannot import safely. */
306+
function mappedReferenceFields(
307+
mapping: Record<number, string>,
308+
fields: ImportWizardProps['fields'],
309+
): ImportWizardProps['fields'] {
310+
const mappedNames = new Set(Object.values(mapping));
311+
return fields.filter((f) => mappedNames.has(f.name) && REFERENCE_IMPORT_TYPES.has(f.type));
312+
}
313+
296314
function validateValue(value: string, type: string): boolean {
297315
if (!value) return true;
298316
switch (type) {
@@ -1237,6 +1255,7 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
12371255
objectName, objectLabel, fields, dataSource, onComplete, onCancel, open, onOpenChange, onErrorMode = 'skip',
12381256
templateStorageKey, templateStorage,
12391257
}) => {
1258+
const { t } = useImportTranslation();
12401259
const [step, setStep] = useState<WizardStep>('upload');
12411260
const [headers, setHeaders] = useState<string[]>([]);
12421261
const [rows, setRows] = useState<string[][]>([]);
@@ -1391,6 +1410,27 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
13911410
// Legacy fallback — per-row `create` with light client-side validation. Used
13921411
// only when the adapter/client can't reach the server `/import` route.
13931412
const legacyImport = useCallback(async () => {
1413+
// Relation columns need the server to resolve display text into record
1414+
// IDs; per-row `create` would store the raw text verbatim. Refuse up
1415+
// front — corrupting relation data is worse than not importing.
1416+
const refFields = mappedReferenceFields(mapping, fields);
1417+
if (refFields.length > 0) {
1418+
const importResult: ImportResult = {
1419+
totalRows: rows.length,
1420+
importedRows: 0,
1421+
skippedRows: rows.length,
1422+
errors: [{
1423+
row: 0,
1424+
field: refFields.map((f) => f.name).join(', '),
1425+
message: t('grid.import.legacyReferenceBlocked', {
1426+
fields: refFields.map((f) => f.label || f.name).join(', '),
1427+
}),
1428+
}],
1429+
};
1430+
setResult(importResult); setImporting(false); onComplete?.(importResult);
1431+
return;
1432+
}
1433+
13941434
const errors: ImportResult['errors'] = [];
13951435
let importedRows = 0, skippedRows = 0;
13961436
const mappedCols = Object.entries(mapping).map(([idx, name]) => ({
@@ -1422,7 +1462,7 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
14221462
}
14231463
const importResult: ImportResult = { totalRows: rows.length, importedRows, skippedRows, errors };
14241464
setResult(importResult); setImporting(false); onComplete?.(importResult);
1425-
}, [rows, mapping, fields, dataSource, objectName, onComplete, onErrorMode, corrections]);
1465+
}, [rows, mapping, fields, dataSource, objectName, onComplete, onErrorMode, corrections, t]);
14261466

14271467
// Large-file path: hand the rows to a server-side background job and poll it
14281468
// to completion. Returns `true` when the async path handled the import
@@ -1675,7 +1715,6 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
16751715
}, [result, headers, rows, mapping, objectName]);
16761716

16771717
const handleClose = useCallback(() => { reset(); onOpenChange?.(false); onCancel?.(); }, [reset, onOpenChange, onCancel]);
1678-
const { t } = useImportTranslation();
16791718

16801719
return (
16811720
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange?.(v); }}>
@@ -1874,7 +1913,7 @@ export const ImportWizard: React.FC<ImportWizardProps> = ({
18741913
<>
18751914
<div className="max-h-32 w-full overflow-auto rounded border p-2 text-xs">
18761915
{result.errors.slice(0, 10).map((err, i) => (
1877-
<p key={i} className="text-destructive">Row {err.row}{err.field ? ` (${err.field})` : ''}: {err.message}</p>
1916+
<p key={i} className="text-destructive">{err.row >= 1 ? `Row ${err.row}${err.field ? ` (${err.field})` : ''}: ` : ''}{err.message}</p>
18781917
))}
18791918
{result.errors.length > 10 && <p className="text-muted-foreground">{t('grid.import.moreErrors', { count: result.errors.length - 10 })}</p>}
18801919
</div>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Legacy-fallback relation guard — end-to-end through the wizard.
3+
*
4+
* When the data source has no `importRecords` (old client/server) the wizard
5+
* falls back to the per-row `create` loop, which stores raw cell text
6+
* verbatim. For relation fields (lookup / master_detail / user / reference /
7+
* tree) that corrupts data — text where a record ID belongs — so the fallback
8+
* must refuse to write anything and surface a clear error instead.
9+
*/
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
12+
import '@testing-library/jest-dom';
13+
import React from 'react';
14+
15+
import { ImportWizard } from '../ImportWizard';
16+
17+
const FIELDS = [
18+
{ name: 'name', label: 'Name', type: 'text' },
19+
{ name: 'account_id', label: 'Account', type: 'lookup' },
20+
];
21+
22+
/** Paste TSV into the upload step via the wizard's window-level handler. */
23+
function pasteRows(text: string) {
24+
const evt = new Event('paste', { bubbles: true, cancelable: true }) as Event & {
25+
clipboardData: { getData: (type: string) => string };
26+
};
27+
evt.clipboardData = { getData: (type: string) => (type === 'text/plain' ? text : '') };
28+
act(() => { window.dispatchEvent(evt); });
29+
}
30+
31+
async function runWizardImport(tsv: string, dataSource: any, onComplete: (r: any) => void) {
32+
render(
33+
<ImportWizard
34+
objectName="task"
35+
fields={FIELDS}
36+
dataSource={dataSource}
37+
open
38+
onOpenChange={() => {}}
39+
onComplete={onComplete}
40+
/>,
41+
);
42+
pasteRows(tsv);
43+
// Headers equal the field labels, so auto-mapping maps every column and
44+
// the wizard lands on the mapping step with Next enabled.
45+
const next = await screen.findByTestId('import-next-btn');
46+
await waitFor(() => expect(next).toBeEnabled());
47+
fireEvent.click(next);
48+
fireEvent.click(await screen.findByTestId('import-run-btn'));
49+
}
50+
51+
describe('ImportWizard legacy fallback: relation columns', () => {
52+
it('blocks the import and writes nothing when a lookup column is mapped', async () => {
53+
const create = vi.fn();
54+
const onComplete = vi.fn();
55+
// No importRecords / createImportJob → wizard can only use the legacy path.
56+
await runWizardImport('Name\tAccount\nAcme\t大唐2026', { create }, onComplete);
57+
58+
await waitFor(() => expect(onComplete).toHaveBeenCalled());
59+
expect(create).not.toHaveBeenCalled();
60+
const result = onComplete.mock.calls[0][0];
61+
expect(result.importedRows).toBe(0);
62+
expect(result.skippedRows).toBe(1);
63+
expect(result.errors).toHaveLength(1);
64+
expect(result.errors[0].field).toBe('account_id');
65+
// The blocked error names the offending field by label.
66+
expect(screen.getByText(/Import blocked: Account/)).toBeInTheDocument();
67+
});
68+
69+
it('still imports normally when no relation column is mapped', async () => {
70+
const create = vi.fn().mockResolvedValue({ id: '1' });
71+
const onComplete = vi.fn();
72+
await runWizardImport('Name\nAcme', { create }, onComplete);
73+
74+
await waitFor(() => expect(onComplete).toHaveBeenCalled());
75+
expect(create).toHaveBeenCalledTimes(1);
76+
expect(create).toHaveBeenCalledWith('task', { name: 'Acme' });
77+
expect(onComplete.mock.calls[0][0].importedRows).toBe(1);
78+
});
79+
});

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { __testables } from './ImportWizard';
33

4-
const { isUnsupportedImport, buildFailedRowsCsv } = __testables;
4+
const { isUnsupportedImport, buildFailedRowsCsv, mappedReferenceFields } = __testables;
55

66
describe('isUnsupportedImport', () => {
77
it('matches the adapter UNSUPPORTED_OPERATION code', () => {
@@ -59,3 +59,40 @@ describe('buildFailedRowsCsv', () => {
5959
expect(csv.split('\n')).toHaveLength(1);
6060
});
6161
});
62+
63+
describe('mappedReferenceFields (legacy-fallback relation guard)', () => {
64+
// The legacy per-row create fallback stores raw cell text verbatim — for
65+
// relation fields that corrupts data (text where a record ID belongs), so
66+
// the fallback refuses to run when any mapped column targets one.
67+
const fields = [
68+
{ name: 'name', label: 'Name', type: 'text' },
69+
{ name: 'account_id', label: 'Account', type: 'lookup' },
70+
{ name: 'parent_id', label: 'Parent', type: 'master_detail' },
71+
{ name: 'owner', label: 'Owner', type: 'user' },
72+
{ name: 'ref', label: 'Ref', type: 'reference' },
73+
{ name: 'node', label: 'Node', type: 'tree' },
74+
{ name: 'amount', label: 'Amount', type: 'number' },
75+
];
76+
77+
it('returns every mapped relation-type field (all five guarded types)', () => {
78+
const mapping = { 0: 'account_id', 1: 'parent_id', 2: 'owner', 3: 'ref', 4: 'node' };
79+
expect(mappedReferenceFields(mapping, fields).map((f) => f.name)).toEqual([
80+
'account_id', 'parent_id', 'owner', 'ref', 'node',
81+
]);
82+
});
83+
84+
it('ignores mapped scalar fields and unmapped relation fields', () => {
85+
// account_id exists on the object but is NOT mapped — must not trigger.
86+
const mapping = { 0: 'name', 1: 'amount' };
87+
expect(mappedReferenceFields(mapping, fields)).toEqual([]);
88+
});
89+
90+
it('flags a mix: only the mapped relation column is returned', () => {
91+
const mapping = { 0: 'name', 1: 'account_id', 2: 'amount' };
92+
expect(mappedReferenceFields(mapping, fields).map((f) => f.name)).toEqual(['account_id']);
93+
});
94+
95+
it('returns [] for an empty mapping', () => {
96+
expect(mappedReferenceFields({}, fields)).toEqual([]);
97+
});
98+
});

0 commit comments

Comments
 (0)