-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathImportWizard.tsx
More file actions
2443 lines (2322 loc) · 112 KB
/
Copy pathImportWizard.tsx
File metadata and controls
2443 lines (2322 loc) · 112 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* ObjectUI – Copyright (c) 2024-present ObjectStack Inc.
* Licensed under MIT. Phase 15 L1: CSV/Excel Import Wizard
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
cn, Button, Badge, Progress, Input, Checkbox, Label,
Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@object-ui/components';
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft, Save, Trash2, ClipboardPaste, Download, Undo2 } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/react';
import { sanitizeFileNameBase } from '@object-ui/core';
import type {
DataSource,
ImportRequestOptions,
ImportRecordsResult,
ImportRowResult,
ImportWriteMode,
CreateImportJobResult,
ImportJobProgressInfo,
ImportJobResultsInfo,
ImportJobStatus,
ImportJobSummaryInfo,
} from '@object-ui/types';
import {
parseSpreadsheetFile, parseClipboardTable, inferColumnType, isTypeCompatible,
suggestColumnMappings, ImportParseError,
type InferredType, type ColumnSuggestion, type MappingConfidence,
} from './importParsers';
import {
asSavedMapping, buildSourceRows, summarizeSavedMapping, savedMappingToDisplayIndexMap,
type SavedMapping,
} from './savedMapping';
/** Default English fallback strings used when no I18nProvider is mounted
* (standalone / unit-test usage). Mirrors the keys under `grid.import.*`. */
const IMPORT_DEFAULT_TRANSLATIONS: Record<string, string> = {
'grid.import.title': 'Import {{object}}',
'grid.import.stepUpload': 'Upload',
'grid.import.stepMapping': 'Mapping',
'grid.import.stepPreview': 'Preview',
'grid.import.uploadDescription': 'Upload a CSV or Excel file, or paste from a spreadsheet to get started.',
'grid.import.mappingDescription': 'Map columns to object fields.',
'grid.import.previewDescription': 'Review data before importing.',
'grid.import.dragDrop': 'Drag & drop a CSV or Excel file here, or click to browse',
'grid.import.browseFiles': 'Browse Files',
'grid.import.downloadTemplate': 'Download template',
'grid.import.downloadTemplateHint': 'Get a CSV with the right columns (required fields marked *).',
'grid.import.templateFileName': '{{object}}-import-template',
'grid.import.parsing': 'Parsing…',
'grid.import.pasteHint': 'or paste (Ctrl/⌘+V) rows copied from Excel or Google Sheets',
'grid.import.legacyXls': "Legacy .xls files aren't supported — please re-save as .xlsx.",
'grid.import.unsupportedFile': 'Unsupported file type. Use CSV, TSV, or Excel (.xlsx).',
'grid.import.parseFailed': 'Could not read this file. Please check the format and try again.',
'grid.import.fileNeedsHeader': 'File must contain a header row and at least one data row.',
'grid.import.mappingTemplate': 'Mapping template:',
'grid.import.chooseTemplate': 'Choose template…',
'grid.import.noSavedTemplates': 'No saved templates',
'grid.import.noneOption': '— None —',
'grid.import.saveCurrent': 'Save current',
'grid.import.templateName': 'Template name',
'grid.import.save': 'Save',
'grid.import.deleteTemplate': 'Delete template',
'grid.import.savedMapping': 'Saved mapping:',
'grid.import.chooseSavedMapping': 'Choose a saved mapping…',
'grid.import.manualMapping': '— Map columns manually —',
'grid.import.transform': 'Transform',
'grid.import.savedMappingHint': "Mapping “{{name}}” applies rename + transforms + type coercion on the server. Column mapping is read-only.",
'grid.import.savedMappingPreviewNote': "The preview shows your source columns; on import, mapping “{{name}}” applies rename, transforms and type coercion on the server.",
'grid.import.csvColumn': 'Column',
'grid.import.mapsTo': 'Maps To',
'grid.import.typeMismatch': 'Looks like {{type}}',
'grid.import.autoMatched': 'Auto-matched',
'grid.import.autoMatchedSummary': 'Auto-matched {{count}} column(s) — review and adjust below.',
'grid.import.confidence.high': 'High confidence',
'grid.import.confidence.medium': 'Medium confidence',
'grid.import.confidence.low': 'Low confidence',
'grid.import.type.number': 'Number',
'grid.import.type.boolean': 'Boolean',
'grid.import.type.date': 'Date',
'grid.import.type.datetime': 'Date & time',
'grid.import.type.text': 'Text',
'grid.import.status': 'Status',
'grid.import.skipColumn': 'Skip column',
'grid.import.skip': '— Skip —',
'grid.import.mapped': 'Mapped',
'grid.import.skipped': 'Skipped',
'grid.import.rowsWithErrors': '{{count}} row(s) with errors',
'grid.import.rowsCorrected': '{{count}} row(s) corrected',
'grid.import.clickToFix': '— click a highlighted cell to fix it inline.',
'grid.import.showingRows': 'Showing {{shown}} of {{total}} rows',
'grid.import.importing': 'Importing… {{progress}}%',
// Async (large-file) import — job queued + processed server-side.
'grid.import.asyncQueued': 'Queued — preparing to import…',
'grid.import.asyncProcessing': 'Importing {{processed}} of {{total}} rows… {{progress}}%',
'grid.import.asyncLargeHint': 'This file is large, so it will be imported in the background.',
'grid.import.largeSampleNotice': 'Previewing the first {{shown}} of {{total}} rows.',
'grid.import.cancelImport': 'Cancel import',
'grid.import.importCancelled': 'Import cancelled',
'grid.import.resultsTruncated': 'Showing the first {{count}} row results (of {{total}}).',
'grid.import.importComplete': 'Import Complete',
'grid.import.imported': '{{count}} imported',
'grid.import.createdCount': '{{count}} created',
'grid.import.updatedCount': '{{count}} updated',
'grid.import.skippedCount': '{{count}} skipped',
'grid.import.moreErrors': '…and {{count}} more errors',
'grid.import.downloadFailed': 'Download failed rows',
// Write-mode / options (preview step)
'grid.import.options': 'Import options',
'grid.import.writeMode': 'When a row matches an existing record',
'grid.import.writeModeOpt.insert': 'Always create new',
'grid.import.writeModeOpt.update': 'Update existing (skip if no match)',
'grid.import.writeModeOpt.upsert': 'Update if matched, else create',
'grid.import.matchFields': 'Match on',
'grid.import.matchFieldsPlaceholder': 'Choose match field(s)…',
'grid.import.matchFieldsHint': 'Rows are matched to existing records by these field(s).',
'grid.import.needMatchFields': 'Select at least one field to match on.',
'grid.import.optCreateOptions': 'Keep unknown option values',
'grid.import.optRunAutomations': 'Run automations & triggers',
'grid.import.optTreatHistorical': 'Import as historical data',
'grid.import.optTreatHistoricalHint': '(import completed records as-is — skip state-machine checks and keep their original timestamps & author instead of stamping now)',
'grid.import.optSkipBlankKey': 'Skip rows with a blank match value',
'grid.import.optBackground': 'Import in the background',
'grid.import.optBackgroundHint': '(runs as an undoable job)',
// Server dry-run pre-check (small files, preview step)
'grid.import.validate': 'Validate data',
'grid.import.validating': 'Validating…',
'grid.import.validateHint': 'Check every row against the server before importing.',
'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.
'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',
// Import-job history
'grid.import.history': 'History',
'grid.import.historyBack': 'Back to import',
'grid.import.historyDescription': 'Recent imports for this object.',
'grid.import.historyHint': 'Background import jobs, newest first.',
'grid.import.historyRefresh': 'Refresh',
'grid.import.historyLoading': 'Loading…',
'grid.import.historyEmpty': 'No imports yet.',
'grid.import.historyUnsupported': 'Import history isn’t available for this data source.',
'grid.import.historyColStatus': 'Status',
'grid.import.historyColRows': 'Rows',
'grid.import.historyColResult': 'Result',
'grid.import.historyColTime': 'When',
'grid.import.errorCount': '{{count}} errors',
// Undo / logical rollback
'grid.import.undoImport': 'Undo import',
'grid.import.undoing': 'Undoing…',
'grid.import.undoConfirm': 'Undo this import? Records it created will be deleted and records it updated will be restored to their previous values.',
'grid.import.reverted': 'Undone',
'grid.import.jobStatus.pending': 'Pending',
'grid.import.jobStatus.running': 'Running',
'grid.import.jobStatus.succeeded': 'Succeeded',
'grid.import.jobStatus.failed': 'Failed',
'grid.import.jobStatus.cancelled': 'Cancelled',
'grid.import.cancel': 'Cancel',
'grid.import.back': 'Back',
'grid.import.next': 'Next',
'grid.import.close': 'Close',
'grid.import.importNRows': 'Import {{count}} Rows',
'grid.import.importingProgress': 'Importing…',
'grid.import.required': 'Required',
'grid.import.invalidType': 'Invalid {{type}}',
'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.',
// Shown in the mapping step when a required field has no column mapped — the
// reason the Next button is disabled. Field names are listed as `label (name)`.
'grid.import.missingRequiredHint': 'Can’t continue — required field(s) not mapped: {{fields}}. Add a matching column to your file, or go back and upload one that includes it.',
// Shown on the completion screen when the import ran via the legacy per-row
// fallback (server `/import` route unavailable) — no server-side coercion.
'grid.import.legacyFallbackNotice': 'Imported via a compatibility fallback: this connection doesn’t support the server import route, so values were saved as text without server-side type coercion. Upgrade the backend/client for full import support (type coercion and relation lookups).',
// Shown when the server refuses import with 405 because the object does not
// expose the import operation (#3391) — distinct from "route not found".
'grid.import.notAllowed': 'This object is not open for import.',
};
/** Apply `{{var}}` interpolation to a translation template. */
function interpolate(template: string, vars?: Record<string, unknown>): string {
if (!vars) return template;
let out = template;
for (const [k, v] of Object.entries(vars)) {
out = out.replace(new RegExp(`{{${k}}}`, 'g'), String(v));
}
return out;
}
/** Translation hook with safe English fallback for standalone usage.
* Mirrors the pattern in ObjectGrid.tsx — when no I18nProvider is mounted
* (e.g. unit tests) the hook still resolves `grid.import.*`
* keys via the embedded defaults so the wizard stays usable. */
function useImportTranslation(): { t: (key: string, vars?: Record<string, unknown>) => string } {
const fallback = (key: string, vars?: Record<string, unknown>) =>
interpolate(IMPORT_DEFAULT_TRANSLATIONS[key] ?? key, vars);
try {
const result = useObjectTranslation();
const probe = result.t('grid.import.title');
if (probe === 'grid.import.title') return { t: fallback };
return {
t: (key, vars) => {
const v = result.t(key, vars as Record<string, unknown> | undefined);
return v === key ? fallback(key, vars) : v;
},
};
} catch {
return { t: fallback };
}
}
/** @internal — exported solely for unit tests. */
export const __testables = {
get mappingToTemplatePayload() { return mappingToTemplatePayload; },
get applyTemplate() { return applyTemplate; },
get loadTemplates() { return loadTemplates; },
get saveTemplates() { return saveTemplates; },
get autoMapColumns() { return autoMapColumns; },
get isUnsupportedImport() { return isUnsupportedImport; },
get mappedReferenceFields() { return mappedReferenceFields; },
get formatDryRunError() { return formatDryRunError; },
get isUnsupportedImportJob() { return isUnsupportedImportJob; },
get isImportNotAllowed() { return isImportNotAllowed; },
get jobResultToImportResult() { return jobResultToImportResult; },
get buildFailedRowsCsv() { return buildFailedRowsCsv; },
get buildImportTemplateCsv() { return buildImportTemplateCsv; },
get assembleImportRequest() { return assembleImportRequest; },
get isImportJobActive() { return isImportJobActive; },
get isImportJobUndoable() { return isImportJobUndoable; },
get buildSourceRows() { return buildSourceRows; },
get summarizeSavedMapping() { return summarizeSavedMapping; },
get savedMappingToDisplayIndexMap() { return savedMappingToDisplayIndexMap; },
};
/** A reusable column-mapping template, persisted across sessions. Keys are
* CSV header names (case-insensitive) so a template can apply across files
* whose columns are reordered or sparsely present. */
export interface ImportMappingTemplate {
id: string;
name: string;
/** Map of CSV header name → object field name. */
mapping: Record<string, string>;
updatedAt: number;
}
/** Minimal localStorage-shaped contract; injectable for tests. */
export interface ImportTemplateStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export interface ImportWizardProps {
objectName: string;
objectLabel?: string;
fields: Array<{
name: string;
label: string;
type: string;
required?: boolean;
/** Allowed values for select/enum fields — used to seed the downloadable
* template's example row. Accepts option objects or bare strings. */
options?: Array<{ label?: string; value?: string | number } | string>;
}>;
dataSource: any;
onComplete?: (result: ImportResult) => void;
onCancel?: () => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Error handling strategy: 'skip' skips invalid rows, 'stop' aborts on first error. @default 'skip' */
onErrorMode?: 'skip' | 'stop';
/** Override the storage key under which mapping templates are persisted.
* Defaults to `objectui:import-templates:${objectName}`. */
templateStorageKey?: string;
/** Override the storage backend (defaults to window.localStorage). Use this
* to disable persistence (`null`) or to inject an in-memory store in tests. */
templateStorage?: ImportTemplateStorage | null;
/** Registered server-side import mappings for this object (framework #2611).
* When omitted, the wizard fetches them via `dataSource.listImportMappings`
* (feature-detected). Pass explicitly to override / for tests. */
savedMappings?: SavedMapping[];
/** Pre-loaded spreadsheet (cloud#797 Excel→App): when the wizard opens with
* this set, it parses the File in memory and jumps straight to the mapping
* step, skipping the upload UI. Used by the AI build panel to hand off an
* already-attached Excel/CSV so the user goes from "one sentence → app" to
* "my real data is in it" without re-picking the file. Ignored on reopen
* once consumed. */
initialFile?: File;
/** Extra content rendered above the write-options panel on the preview step.
* Hosts inject domain-specific import options here (e.g. the identity
* import's password policy — framework#2782); any state it collects flows
* back through the host's own `dataSource` wrapper, so the wizard stays
* backend-agnostic. */
extraOptionsContent?: React.ReactNode;
/** Render extra content at the top of the result step — e.g. one-time
* credentials a domain-specific import endpoint returned in
* `result.serverResult` (framework#2782). */
renderResultExtra?: (result: ImportResult) => React.ReactNode;
}
export interface ImportResult {
totalRows: number;
importedRows: number;
skippedRows: number;
errors: Array<{ row: number; field: string; message: string }>;
/** Rows that created a new record (server-side import). */
createdRows?: number;
/** Rows that updated an existing record (server-side import). */
updatedRows?: number;
/** The raw per-row server result, when the server `/import` path was used. */
serverResult?: ImportRecordsResult;
/** True when an async job's per-row `errors` were capped by the server. */
resultsTruncated?: boolean;
/** True when the user cancelled an in-flight async import job. */
cancelled?: boolean;
/** True when the import ran via the legacy per-row `create` fallback because
* the server `/import` route was unavailable — values are written as-is,
* with no server-side type coercion or reference resolution. Surfaced on the
* completion screen so a silent downgrade never passes for a full import. */
degraded?: boolean;
}
type WizardStep = 'upload' | 'mapping' | 'preview';
/** Maximum number of rows to show in the preview step */
const PREVIEW_ROW_COUNT = 10;
/**
* Row count above which the wizard prefers an asynchronous import job (when the
* data source supports it) instead of the synchronous single-call import. Kept
* in step with the server's synchronous `/import` ceiling (`maxRows: 5000`), so
* files the sync route would reject with 413 are routed to a background job.
*/
const ASYNC_IMPORT_THRESHOLD = 5000;
/** How often (ms) to poll an in-flight import job for progress. */
const IMPORT_JOB_POLL_INTERVAL = 800;
/** Text colour for the auto-match confidence hint, keyed by confidence bucket. */
const CONFIDENCE_CLASS: Record<MappingConfidence, string> = {
high: 'text-emerald-600',
medium: 'text-sky-600',
low: 'text-muted-foreground',
};
/** Boolean tokens the server's import coercion accepts (import-coerce.ts).
* Kept in sync so the preview step doesn't flag a cell the server would take
* (e.g. Chinese 是/否, on/off, ✓/×). Compared case-insensitively. */
const BOOLEAN_IMPORT_TOKENS = new Set([
'true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√',
'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×',
]);
/** Field types the server resolves from display text to record IDs during
* `/import` (kept in step with the server's import-coerce REFERENCE_TYPES).
* The legacy per-row create fallback has no resolution step — raw cell text
* would be stored verbatim into relation fields — so the fallback must refuse
* to run when any mapped column targets one of these types. */
const REFERENCE_IMPORT_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);
/** Mapped fields whose type the legacy fallback cannot import safely. */
function mappedReferenceFields(
mapping: Record<number, string>,
fields: ImportWizardProps['fields'],
): ImportWizardProps['fields'] {
const mappedNames = new Set(Object.values(mapping));
return fields.filter((f) => mappedNames.has(f.name) && REFERENCE_IMPORT_TYPES.has(f.type));
}
/** Pull the first double-quoted token out of a server error message — e.g.
* `no os_..._product matches "导管架"` → `导管架`. A locale-agnostic fallback for
* naming the offending value when it can't be read back from the row. */
function extractQuotedValue(message?: string): string | undefined {
const m = message?.match(/"([^"]*)"/);
return m?.[1];
}
/**
* Turn one failed dry-run row into a friendly, localizable error line.
*
* The server keys each error by a field's api-name, bakes that same api-name
* into an English message (`product: no os_..._product matches "..."`), and
* tags it with a structured `code`. Rendered verbatim that reads as
* `产品: product: no os_..._product matches "..."` — the field twice, an
* internal object name, all in English. So we drive the message off `code`
* (localized, with the offending value), resolve the api-name to its human
* label, and only fall back to the raw server text — minus any duplicated
* `<api-name>:` prefix — for codes we don't recognize.
*/
function formatDryRunError(
r: Pick<ImportRowResult, 'field' | 'error' | 'code'>,
fieldLabelByName: Map<string, string>,
value: string | undefined,
t: (key: string, vars?: Record<string, unknown>) => string,
): { fieldLabel?: string; message: string } {
const fieldLabel = r.field ? (fieldLabelByName.get(r.field) ?? r.field) : undefined;
// Prefer the value the row actually supplied; fall back to the token the
// server echoed into its message.
const shown = (value ?? '').trim() || extractQuotedValue(r.error) || '';
switch (r.code) {
case 'reference_not_found':
return { fieldLabel, message: t('grid.import.referenceNotFound', { value: shown }) };
case 'reference_ambiguous':
return { fieldLabel, message: t('grid.import.referenceAmbiguous', { value: shown }) };
}
let message = (r.error ?? r.code ?? '').trim();
// Drop a leading `<api-name>:` the server prepended, so it isn't shown on top
// of the label we render.
if (r.field && message.toLowerCase().startsWith(`${r.field.toLowerCase()}:`)) {
message = message.slice(r.field.length + 1).trimStart();
}
return { fieldLabel, message };
}
/**
* Plausible email? Mirrors the server's `isLikelyEmail` (structure + ASCII) so
* an obviously-bad address — e.g. a non-ASCII domain like `x@柴仟.com` — is
* flagged red in the preview here, instead of passing client + dry-run
* validation only to be rejected by better-auth at real-import time
* (framework#3566). Deliberately not a regex: a single-pass structural check
* has no backtracking (cf. the server-side ReDoS note).
*/
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
const at = value.indexOf('@');
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false;
const domain = value.slice(at + 1);
const dot = domain.lastIndexOf('.');
return dot > 0 && dot < domain.length - 1;
}
function validateValue(value: string, type: string): boolean {
if (!value) return true;
switch (type) {
case 'number': case 'currency': case 'percent': return !isNaN(Number(value));
case 'boolean': return BOOLEAN_IMPORT_TOKENS.has(value.trim().toLowerCase());
case 'date': case 'datetime': return !isNaN(Date.parse(value));
case 'email': return isPlausibleEmail(value.trim());
default: return true;
}
}
/**
* Auto-map source columns to object fields, Airtable-style. Delegates to
* {@link suggestColumnMappings} (name/label similarity + bilingual synonyms +
* token overlap + content-inferred type gating, assigned globally by
* confidence) and keeps only the confidently-matched columns. `rows` is
* optional; without it only name-based signals fire.
*/
function autoMapColumns(
headers: string[],
fields: ImportWizardProps['fields'],
rows?: string[][],
): Record<number, string> {
const mapping: Record<number, string> = {};
for (const s of suggestColumnMappings(headers, fields, rows)) {
if (s.fieldName) mapping[s.columnIndex] = s.fieldName;
}
return mapping;
}
/** Resolve the storage backend, defaulting to window.localStorage when available. */
function defaultTemplateStorage(): ImportTemplateStorage | null {
if (typeof window === 'undefined') return null;
try { return window.localStorage; } catch { return null; }
}
/** Load and persist named mapping templates. Header keys are stored
* case-insensitively so templates apply across files with different casing. */
function loadTemplates(storage: ImportTemplateStorage | null, key: string): ImportMappingTemplate[] {
if (!storage) return [];
try {
const raw = storage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((t) => t && t.id && t.name && t.mapping) : [];
} catch { return []; }
}
function saveTemplates(storage: ImportTemplateStorage | null, key: string, templates: ImportMappingTemplate[]) {
if (!storage) return;
try { storage.setItem(key, JSON.stringify(templates)); } catch { /* quota etc */ }
}
/** Convert an index-based mapping back to a header-name template payload. */
function mappingToTemplatePayload(headers: string[], mapping: Record<number, string>): Record<string, string> {
const payload: Record<string, string> = {};
Object.entries(mapping).forEach(([idx, fieldName]) => {
const header = headers[Number(idx)];
if (header) payload[header.trim().toLowerCase()] = fieldName;
});
return payload;
}
/** Apply a header-name template to current headers, producing an index map. */
function applyTemplate(
template: ImportMappingTemplate,
headers: string[],
fields: ImportWizardProps['fields'],
): Record<number, string> {
const validFieldNames = new Set(fields.map((f) => f.name));
const next: Record<number, string> = {};
headers.forEach((header, idx) => {
const fieldName = template.mapping[header.trim().toLowerCase()];
if (fieldName && validFieldNames.has(fieldName)) next[idx] = fieldName;
});
return next;
}
type MappedCol = { csvIdx: number; field: ImportWizardProps['fields'][0] };
function validateRow(row: string[], mappedCols: MappedCol[], rowIndex: number) {
const errors: ImportResult['errors'] = [];
const record: Record<string, any> = {};
for (const col of mappedCols) {
const raw = row[col.csvIdx] ?? '';
if (col.field.required && !raw) {
errors.push({ row: rowIndex, field: col.field.name, message: 'Required field is empty' });
continue;
}
if (raw && !validateValue(raw, col.field.type)) {
errors.push({ row: rowIndex, field: col.field.name, message: `Invalid ${col.field.type} value: "${raw}"` });
continue;
}
record[col.field.name] = raw;
}
return { record, errors };
}
/** Assemble the server `/import` request from mapping-applied raw rows plus the
* current write-mode + coercion options. Kept pure (no component state) so the
* real import and the dry-run pre-check send byte-identical payloads and it can
* be unit-tested. `matchFields` is only sent when the write-mode consults it. */
function assembleImportRequest(
rows: Record<string, string>[],
opts: {
writeMode: ImportWriteMode;
matchFields: string[];
createMissingOptions: boolean;
runAutomations: boolean;
treatAsHistorical?: boolean;
skipBlankMatchKey: boolean;
dryRun?: boolean;
/** When set, the server resolves this registered mapping and owns the
* rename + transform + write semantics (framework #2611). `rows` must
* then carry SOURCE headers (see buildSourceRows), and the inline
* column mapping / write-mode are omitted — mutually exclusive per the
* server contract. `runAutomations` is still honored. */
mappingName?: string;
},
): ImportRequestOptions {
if (opts.mappingName) {
return {
format: 'json',
rows,
mappingName: opts.mappingName,
runAutomations: opts.runAutomations,
...(opts.treatAsHistorical ? { treatAsHistorical: true } : {}),
...(opts.dryRun ? { dryRun: true } : {}),
};
}
return {
format: 'json',
rows,
writeMode: opts.writeMode,
...(opts.writeMode !== 'insert' ? { matchFields: opts.matchFields } : {}),
createMissingOptions: opts.createMissingOptions,
runAutomations: opts.runAutomations,
...(opts.treatAsHistorical ? { treatAsHistorical: true } : {}),
skipBlankMatchKey: opts.skipBlankMatchKey,
...(opts.dryRun ? { dryRun: true } : {}),
};
}
/** True when the adapter/client can't speak the server `/import` route, so the
* wizard should transparently fall back to a per-row `create` loop. */
function isUnsupportedImport(err: unknown): boolean {
const code = (err as { code?: unknown })?.code;
if (code === 'UNSUPPORTED_OPERATION') return true;
const msg = err instanceof Error ? err.message : '';
return /does not support data\.import|importRecords is not a function|\.import is not a function/i.test(msg);
}
/** True when the data source lacks the async import-job API (older
* adapter/client/server), so the wizard should fall back to the sync path. */
function isUnsupportedImportJob(err: unknown): boolean {
const code = (err as { code?: unknown })?.code;
if (code === 'UNSUPPORTED_OPERATION') return true;
const msg = err instanceof Error ? err.message : '';
return /does not support async import|createImportJob is not a function|import\/jobs|404/i.test(msg);
}
/** True when the SERVER refused the import because the object does not expose
* the import operation (405 / `OBJECT_API_METHOD_NOT_ALLOWED`, #3391).
*
* The opposite of {@link isUnsupportedImportJob} (404 = the async-job ROUTE is
* absent → fall back to sync) and {@link isUnsupportedImport} (adapter can't
* speak `/import` → fall back to per-row create): a **405** means "this object
* is not open for import at all", so every fallback would 405 too. The wizard
* must STOP and show a dedicated message — never fall back. Checked BEFORE the
* unsupported predicates at every catch site so 405 wins over the 404/regex
* fall-back paths. */
function isImportNotAllowed(err: unknown): boolean {
const code = (err as { code?: unknown })?.code;
if (code === 'OBJECT_API_METHOD_NOT_ALLOWED') return true;
const e = err as { status?: unknown; statusCode?: unknown; httpStatus?: unknown };
if (e?.status === 405 || e?.statusCode === 405 || e?.httpStatus === 405) return true;
const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
return /\b405\b|method not allowed/i.test(msg);
}
/** Map an async import-job's final results payload onto the wizard's
* {@link ImportResult} shape — identical to the synchronous mapping so the
* completion screen renders the same regardless of which path ran. */
function jobResultToImportResult(res: ImportJobResultsInfo): ImportResult {
return {
totalRows: res.total,
importedRows: res.created + res.updated,
skippedRows: res.skipped + res.errors,
createdRows: res.created,
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' })),
resultsTruncated: res.resultsTruncated,
};
}
/** True while an import job is still in flight — it can be cancelled and the
* history list should keep polling it. Terminal states are the rest. */
function isImportJobActive(status: ImportJobStatus): boolean {
return status === 'pending' || status === 'running';
}
/** Whether to show the "Undo import" button for a history row: the adapter must
* support undo, the job must be terminal, still undoable, and not already
* reverted. Mirrors the server's `importJobUndoable`. */
function isImportJobUndoable(job: Pick<ImportJobSummaryInfo, 'status' | 'undoable' | 'revertedAt'>, canUndo: boolean): boolean {
return canUndo && !!job.undoable && !job.revertedAt && !isImportJobActive(job.status);
}
/** Build a CSV blob of failed rows for re-export: the original mapped columns
* plus an `_error` column, so a user can fix and re-import just the failures. */
function buildFailedRowsCsv(
headers: string[],
rows: string[][],
mapping: Record<number, string>,
errorsByRow: Map<number, string>,
): string {
const cols = Object.keys(mapping).map(Number).sort((a, b) => a - b);
const esc = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
const head = [...cols.map((c) => headers[c] ?? `col${c}`), '_error'];
const lines = [head.map(esc).join(',')];
// errorsByRow is keyed by 1-based row number.
for (const [rowNum, message] of errorsByRow) {
const src = rows[rowNum - 1];
if (!src) continue;
lines.push([...cols.map((c) => esc(src[c] ?? '')), esc(message)].join(','));
}
return lines.join('\n');
}
/** Pick a representative allowed value from a select field's options, for the
* template example row. Prefers the display label over the stored value: the
* server's import coercion accepts either (it matches value OR label,
* case-insensitively), and the label is what a localized user recognizes —
* an ASCII slug like `prepare` reads as English leakage in a zh template. */
function firstOptionValue(
options: ImportWizardProps['fields'][number]['options'],
): string | undefined {
const first = options?.[0];
if (first === undefined || first === null) return undefined;
if (typeof first === 'string') return first;
if (first.label) return first.label;
if (first.value !== undefined && first.value !== null) return String(first.value);
return undefined;
}
/** A type-appropriate example cell for the downloadable import template. Kept
* format-oriented (dates, emails) rather than prose so it reads the same in
* any locale; text-ish fields are left blank so the row is obviously a sample. */
function exampleForField(field: ImportWizardProps['fields'][number]): string {
switch (field.type) {
case 'number':
case 'currency':
case 'percent':
return '0';
case 'date':
return '2024-01-31';
case 'datetime':
return '2024-01-31 09:00';
case 'time':
return '09:00';
case 'boolean':
return 'true';
case 'email':
return 'name@example.com';
case 'url':
return 'https://example.com';
case 'select':
case 'multiselect':
case 'lookup':
case 'reference':
return firstOptionValue(field.options) ?? '';
default:
return '';
}
}
/** Build a downloadable CSV import template for the given fields: a header row
* of field labels (required fields marked with `*`, which re-import tolerates)
* plus a single example row. Not persisted — a convenience starting point. */
function buildImportTemplateCsv(fields: ImportWizardProps['fields']): string {
const esc = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
const header = fields.map((f) => `${f.label}${f.required ? ' *' : ''}`);
const example = fields.map((f) => exampleForField(f));
return [header.map(esc).join(','), example.map(esc).join(',')].join('\n');
}
/** Trigger a client-side text file download (prepends a UTF-8 BOM so Excel
* reads non-ASCII correctly). No-op in non-DOM environments. */
function downloadTextFile(filename: string, text: string, mime = 'text/csv;charset=utf-8'): void {
if (typeof document === 'undefined' || typeof URL?.createObjectURL !== 'function') return;
const blob = new Blob([`\uFEFF${text}`], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/** Map a thrown import-parse error code to a translated, user-facing message. */
function parseErrorMessage(err: unknown, t: (k: string, v?: Record<string, unknown>) => string): string {
const code = err instanceof Error ? err.message : '';
if (code === ImportParseError.LegacyXls) return t('grid.import.legacyXls');
if (code === ImportParseError.Unsupported) return t('grid.import.unsupportedFile');
return t('grid.import.parseFailed');
}
// Step 1: File Upload (CSV / Excel / paste)
const StepUpload: React.FC<{
onFileLoaded: (headers: string[], rows: string[][]) => void;
fields: ImportWizardProps['fields'];
objectName: string;
/** Localized display label — used for the template filename so a zh user
* downloads `合同-导入模板.csv` rather than `contracts-template.csv`. */
objectLabel?: string;
}> = ({ onFileLoaded, fields, objectName, objectLabel }) => {
const { t } = useImportTranslation();
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
/** Validate a freshly-parsed grid and hand it to the wizard, or report why not. */
const acceptParsed = useCallback((parsed: string[][]) => {
if (parsed.length < 2) { setError(t('grid.import.fileNeedsHeader')); return false; }
onFileLoaded(parsed[0], parsed.slice(1));
return true;
}, [onFileLoaded, t]);
const processFile = useCallback(async (file: File) => {
setError(null); setBusy(true);
try {
acceptParsed(await parseSpreadsheetFile(file));
} catch (err) {
setError(parseErrorMessage(err, t));
} finally {
setBusy(false);
}
}, [acceptParsed, t]);
// Paste-to-import: while this step is mounted, intercept paste of tabular
// data copied from Excel/Sheets. Ignored when focus is in a text input so we
// don't hijack ordinary editing.
const handlePaste = useCallback((e: ClipboardEvent) => {
const el = e.target as HTMLElement | null;
if (el && /^(input|textarea)$/i.test(el.tagName)) return;
const data = e.clipboardData;
if (!data) return;
const parsed = parseClipboardTable(data.getData('text/html') || null, data.getData('text/plain') || null);
if (!parsed) return;
e.preventDefault();
setError(null);
if (!acceptParsed(parsed)) { /* message already set */ }
}, [acceptParsed]);
useEffect(() => {
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}, [handlePaste]);
return (
<div className="flex flex-col items-center gap-4 py-6">
<div
className={cn(
'flex w-full flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 transition-colors',
dragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25',
busy && 'pointer-events-none opacity-60',
)}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files[0]; if (f) void processFile(f); }}
>
<Upload className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{busy ? t('grid.import.parsing') : t('grid.import.dragDrop')}</p>
<label>
<input type="file" accept=".csv,.tsv,.txt,.xlsx,.xlsm" className="hidden" disabled={busy} onChange={(e) => { const f = e.target.files?.[0]; if (f) void processFile(f); }} />
<Button variant="outline" size="sm" asChild><span>{t('grid.import.browseFiles')}</span></Button>
</label>
<p className="flex items-center gap-1 text-xs text-muted-foreground/80">
<ClipboardPaste className="h-3.5 w-3.5" /> {t('grid.import.pasteHint')}
</p>
</div>
{fields.length > 0 && (
<div className="flex flex-col items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const base = sanitizeFileNameBase(
t('grid.import.templateFileName', { object: objectLabel || objectName || 'import' }),
);
downloadTextFile(`${base || 'import-template'}.csv`, buildImportTemplateCsv(fields));
}}
data-testid="import-download-template"
>
<Download className="mr-1 h-4 w-4" /> {t('grid.import.downloadTemplate')}
</Button>
<p className="text-xs text-muted-foreground/70">{t('grid.import.downloadTemplateHint')}</p>
</div>
)}
{error && (
<p className="flex items-center gap-1 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> {error}
</p>
)}
</div>
);
};
// Template bar for save / load / delete of column-mapping templates.
const TemplateBar: React.FC<{
templates: ImportMappingTemplate[];
selectedId: string | null;
onSelect: (id: string) => void;
onSaveAs: (name: string) => void;
onDelete: () => void;
disabled?: boolean;
}> = ({ templates, selectedId, onSelect, onSaveAs, onDelete, disabled }) => {
const { t } = useImportTranslation();
const [savingName, setSavingName] = useState('');
const [showSave, setShowSave] = useState(false);
return (
<div
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2"
data-testid="import-template-bar"
>
<Save className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{t('grid.import.mappingTemplate')}</span>
<Select
value={selectedId ?? '__none__'}
onValueChange={(v) => v !== '__none__' && onSelect(v)}
>
<SelectTrigger className="h-7 w-48 text-xs" data-testid="import-template-select">
<SelectValue placeholder={templates.length ? t('grid.import.chooseTemplate') : t('grid.import.noSavedTemplates')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" disabled={templates.length === 0}>
{templates.length ? t('grid.import.noneOption') : t('grid.import.noSavedTemplates')}
</SelectItem>
{templates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>{tpl.name}</SelectItem>
))}
</SelectContent>
</Select>
{!showSave ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowSave(true)}
disabled={disabled}
data-testid="import-template-save-btn"
>
{t('grid.import.saveCurrent')}
</Button>
) : (
<div className="flex items-center gap-1">
<Input
value={savingName}
onChange={(e) => setSavingName(e.target.value)}
placeholder={t('grid.import.templateName')}
className="h-7 w-40 text-xs"
data-testid="import-template-name-input"
autoFocus
/>
<Button
type="button"
size="sm"
onClick={() => { if (savingName.trim()) { onSaveAs(savingName.trim()); setSavingName(''); setShowSave(false); } }}
disabled={!savingName.trim() || disabled}
data-testid="import-template-confirm-save"
>
{t('grid.import.save')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setShowSave(false); setSavingName(''); }}>
{t('grid.import.cancel')}
</Button>
</div>
)}
{selectedId && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDelete}
aria-label={t('grid.import.deleteTemplate')}
data-testid="import-template-delete-btn"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
};
/** Selector for a registered server-side import mapping (framework #2611).
* Picking one hands rename + transforms to the server; the manual column
* table is replaced by a read-only summary of the artifact. */
const SavedMappingBar: React.FC<{
mappings: SavedMapping[];
activeName: string | null;
onSelect: (name: string) => void;
onClear: () => void;
}> = ({ mappings, activeName, onSelect, onClear }) => {
const { t } = useImportTranslation();
if (mappings.length === 0) return null;
return (
<div
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2"
data-testid="import-saved-mapping-bar"
>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">{t('grid.import.savedMapping')}</span>
<Select value={activeName ?? '__none__'} onValueChange={(v) => (v === '__none__' ? onClear() : onSelect(v))}>
<SelectTrigger className="h-7 w-56 text-xs" data-testid="import-saved-mapping-select">
<SelectValue placeholder={t('grid.import.chooseSavedMapping')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t('grid.import.manualMapping')}</SelectItem>
{mappings.map((m) => (
<SelectItem key={m.name} value={m.name}>{m.label || m.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
/** Read-only summary of the chosen server mapping — source → target (transform)
* per fieldMapping entry. Names the transforms without re-running them. */
const SavedMappingSummary: React.FC<{ mapping: SavedMapping }> = ({ mapping }) => {
const { t } = useImportTranslation();
const rows = summarizeSavedMapping(mapping);
return (
<div data-testid="import-saved-mapping-summary">
<p className="mb-2 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t('grid.import.savedMappingHint', { name: mapping.label || mapping.name })}</span>
</p>
<div className="max-h-[420px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('grid.import.csvColumn')}</TableHead>
<TableHead>{t('grid.import.mapsTo')}</TableHead>
<TableHead className="w-32 text-center">{t('grid.import.transform')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{r.source}</TableCell>
<TableCell>{r.target}</TableCell>
<TableCell className="text-center">
{r.transform
? <Badge variant="outline" className="text-[10px] font-normal">{r.transform}</Badge>
: <span className="text-muted-foreground">—</span>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>