@object-ui/plugin-grid 3.0.3 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +12 -6
- package/dist/index.js +2169 -922
- package/dist/index.umd.cjs +9 -3
- package/dist/plugin-grid/src/FormulaBar.d.ts +29 -0
- package/dist/plugin-grid/src/GroupRow.d.ts +23 -0
- package/dist/plugin-grid/src/ImportWizard.d.ts +29 -0
- package/dist/plugin-grid/src/ObjectGrid.d.ts +1 -0
- package/dist/plugin-grid/src/SplitPaneGrid.d.ts +22 -0
- package/dist/plugin-grid/src/components/BulkActionBar.d.ts +12 -0
- package/dist/plugin-grid/src/components/RowActionMenu.d.ts +23 -0
- package/dist/plugin-grid/src/index.d.ts +22 -2
- package/dist/plugin-grid/src/useCellClipboard.d.ts +47 -0
- package/dist/plugin-grid/src/useColumnSummary.d.ts +25 -0
- package/dist/plugin-grid/src/useGradientColor.d.ts +37 -0
- package/dist/plugin-grid/src/useGroupReorder.d.ts +34 -0
- package/dist/plugin-grid/src/useGroupedData.d.ts +24 -3
- package/package.json +10 -10
- package/src/FormulaBar.tsx +151 -0
- package/src/GroupRow.tsx +69 -0
- package/src/ImportWizard.tsx +412 -0
- package/src/ListColumnExtensions.test.tsx +4 -5
- package/src/ObjectGrid.tsx +994 -139
- package/src/SplitPaneGrid.tsx +120 -0
- package/src/VirtualGrid.tsx +2 -2
- package/src/__tests__/GroupRow.test.tsx +206 -0
- package/src/__tests__/ImportPreview.test.tsx +171 -0
- package/src/__tests__/accessorKey-inference.test.tsx +132 -0
- package/src/__tests__/airtable-style.test.tsx +508 -0
- package/src/__tests__/column-features.test.tsx +490 -0
- package/src/__tests__/grid-export.test.tsx +121 -0
- package/src/__tests__/mobile-card-view.test.tsx +355 -0
- package/src/__tests__/objectdef-enrichment.test.tsx +566 -0
- package/src/__tests__/phase11-features.test.tsx +418 -0
- package/src/__tests__/row-bulk-actions.test.tsx +413 -0
- package/src/__tests__/row-height.test.tsx +160 -0
- package/src/__tests__/useGroupedData.test.ts +165 -0
- package/src/components/BulkActionBar.tsx +66 -0
- package/src/components/RowActionMenu.tsx +91 -0
- package/src/index.tsx +46 -2
- package/src/useCellClipboard.ts +136 -0
- package/src/useColumnSummary.ts +128 -0
- package/src/useGradientColor.ts +103 -0
- package/src/useGroupReorder.ts +123 -0
- package/src/useGroupedData.ts +69 -4
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI – Copyright (c) 2024-present ObjectStack Inc.
|
|
3
|
+
* Licensed under MIT. Phase 15 L1: CSV/Excel Import Wizard
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
cn, Button, Badge, Progress,
|
|
9
|
+
Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription,
|
|
10
|
+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
11
|
+
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
12
|
+
} from '@object-ui/components';
|
|
13
|
+
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft } from 'lucide-react';
|
|
14
|
+
|
|
15
|
+
export interface ImportWizardProps {
|
|
16
|
+
objectName: string;
|
|
17
|
+
objectLabel?: string;
|
|
18
|
+
fields: Array<{ name: string; label: string; type: string; required?: boolean }>;
|
|
19
|
+
dataSource: any;
|
|
20
|
+
onComplete?: (result: ImportResult) => void;
|
|
21
|
+
onCancel?: () => void;
|
|
22
|
+
open?: boolean;
|
|
23
|
+
onOpenChange?: (open: boolean) => void;
|
|
24
|
+
/** Error handling strategy: 'skip' skips invalid rows, 'stop' aborts on first error. @default 'skip' */
|
|
25
|
+
onErrorMode?: 'skip' | 'stop';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ImportResult {
|
|
29
|
+
totalRows: number;
|
|
30
|
+
importedRows: number;
|
|
31
|
+
skippedRows: number;
|
|
32
|
+
errors: Array<{ row: number; field: string; message: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type WizardStep = 'upload' | 'mapping' | 'preview';
|
|
36
|
+
|
|
37
|
+
/** Maximum number of rows to show in the preview step */
|
|
38
|
+
const PREVIEW_ROW_COUNT = 10;
|
|
39
|
+
|
|
40
|
+
/** CSV parser with quote handling */
|
|
41
|
+
function parseCSV(text: string): string[][] {
|
|
42
|
+
const rows: string[][] = [];
|
|
43
|
+
let row: string[] = [];
|
|
44
|
+
let field = '';
|
|
45
|
+
let inQuotes = false;
|
|
46
|
+
for (let i = 0; i < text.length; i++) {
|
|
47
|
+
const ch = text[i];
|
|
48
|
+
const next = text[i + 1];
|
|
49
|
+
if (inQuotes) {
|
|
50
|
+
if (ch === '"' && next === '"') { field += '"'; i++; }
|
|
51
|
+
else if (ch === '"') inQuotes = false;
|
|
52
|
+
else field += ch;
|
|
53
|
+
} else if (ch === '"') { inQuotes = true; }
|
|
54
|
+
else if (ch === ',') { row.push(field.trim()); field = ''; }
|
|
55
|
+
else if (ch === '\n' || (ch === '\r' && next === '\n')) {
|
|
56
|
+
row.push(field.trim());
|
|
57
|
+
if (row.some((c) => c !== '')) rows.push(row);
|
|
58
|
+
row = []; field = '';
|
|
59
|
+
if (ch === '\r') i++;
|
|
60
|
+
} else { field += ch; }
|
|
61
|
+
}
|
|
62
|
+
row.push(field.trim());
|
|
63
|
+
if (row.some((c) => c !== '')) rows.push(row);
|
|
64
|
+
return rows;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateValue(value: string, type: string): boolean {
|
|
68
|
+
if (!value) return true;
|
|
69
|
+
switch (type) {
|
|
70
|
+
case 'number': case 'currency': case 'percent': return !isNaN(Number(value));
|
|
71
|
+
case 'boolean': return ['true', 'false', '1', '0', 'yes', 'no'].includes(value.toLowerCase());
|
|
72
|
+
case 'date': case 'datetime': return !isNaN(Date.parse(value));
|
|
73
|
+
default: return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function autoMapColumns(headers: string[], fields: ImportWizardProps['fields']): Record<number, string> {
|
|
78
|
+
const mapping: Record<number, string> = {};
|
|
79
|
+
headers.forEach((header, idx) => {
|
|
80
|
+
const h = header.toLowerCase().replace(/[_\s-]/g, '');
|
|
81
|
+
const match = fields.find((f) => {
|
|
82
|
+
const name = f.name.toLowerCase().replace(/[_\s-]/g, '');
|
|
83
|
+
const label = f.label.toLowerCase().replace(/[_\s-]/g, '');
|
|
84
|
+
return name === h || label === h;
|
|
85
|
+
});
|
|
86
|
+
if (match) mapping[idx] = match.name;
|
|
87
|
+
});
|
|
88
|
+
return mapping;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type MappedCol = { csvIdx: number; field: ImportWizardProps['fields'][0] };
|
|
92
|
+
|
|
93
|
+
function validateRow(row: string[], mappedCols: MappedCol[], rowIndex: number) {
|
|
94
|
+
const errors: ImportResult['errors'] = [];
|
|
95
|
+
const record: Record<string, any> = {};
|
|
96
|
+
for (const col of mappedCols) {
|
|
97
|
+
const raw = row[col.csvIdx] ?? '';
|
|
98
|
+
if (col.field.required && !raw) {
|
|
99
|
+
errors.push({ row: rowIndex, field: col.field.name, message: 'Required field is empty' });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (raw && !validateValue(raw, col.field.type)) {
|
|
103
|
+
errors.push({ row: rowIndex, field: col.field.name, message: `Invalid ${col.field.type} value: "${raw}"` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
record[col.field.name] = raw;
|
|
107
|
+
}
|
|
108
|
+
return { record, errors };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Step 1: File Upload
|
|
112
|
+
const StepUpload: React.FC<{ onFileLoaded: (headers: string[], rows: string[][]) => void }> = ({ onFileLoaded }) => {
|
|
113
|
+
const [dragOver, setDragOver] = useState(false);
|
|
114
|
+
const [error, setError] = useState<string | null>(null);
|
|
115
|
+
|
|
116
|
+
const processFile = useCallback((file: File) => {
|
|
117
|
+
setError(null);
|
|
118
|
+
if (!file.name.endsWith('.csv')) { setError('Only CSV files are supported.'); return; }
|
|
119
|
+
const reader = new FileReader();
|
|
120
|
+
reader.onload = (e) => {
|
|
121
|
+
const parsed = parseCSV(e.target?.result as string);
|
|
122
|
+
if (parsed.length < 2) { setError('File must contain a header row and at least one data row.'); return; }
|
|
123
|
+
onFileLoaded(parsed[0], parsed.slice(1));
|
|
124
|
+
};
|
|
125
|
+
reader.readAsText(file);
|
|
126
|
+
}, [onFileLoaded]);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="flex flex-col items-center gap-4 py-6">
|
|
130
|
+
<div
|
|
131
|
+
className={cn(
|
|
132
|
+
'flex w-full flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 transition-colors',
|
|
133
|
+
dragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25',
|
|
134
|
+
)}
|
|
135
|
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
136
|
+
onDragLeave={() => setDragOver(false)}
|
|
137
|
+
onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files[0]; if (f) processFile(f); }}
|
|
138
|
+
>
|
|
139
|
+
<Upload className="h-10 w-10 text-muted-foreground" />
|
|
140
|
+
<p className="text-sm text-muted-foreground">Drag & drop a CSV file here, or click to browse</p>
|
|
141
|
+
<label>
|
|
142
|
+
<input type="file" accept=".csv" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) processFile(f); }} />
|
|
143
|
+
<Button variant="outline" size="sm" asChild><span>Browse Files</span></Button>
|
|
144
|
+
</label>
|
|
145
|
+
</div>
|
|
146
|
+
{error && (
|
|
147
|
+
<p className="flex items-center gap-1 text-sm text-destructive">
|
|
148
|
+
<AlertCircle className="h-4 w-4" /> {error}
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Step 2: Column Mapping
|
|
156
|
+
const StepMapping: React.FC<{
|
|
157
|
+
headers: string[];
|
|
158
|
+
fields: ImportWizardProps['fields'];
|
|
159
|
+
mapping: Record<number, string>;
|
|
160
|
+
onMappingChange: (mapping: Record<number, string>) => void;
|
|
161
|
+
}> = ({ headers, fields, mapping, onMappingChange }) => {
|
|
162
|
+
const usedFields = useMemo(() => new Set(Object.values(mapping)), [mapping]);
|
|
163
|
+
const handleChange = useCallback((colIdx: number, fieldName: string) => {
|
|
164
|
+
const next = { ...mapping };
|
|
165
|
+
if (fieldName === '__skip__') delete next[colIdx]; else next[colIdx] = fieldName;
|
|
166
|
+
onMappingChange(next);
|
|
167
|
+
}, [mapping, onMappingChange]);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="max-h-[360px] overflow-auto">
|
|
171
|
+
<Table>
|
|
172
|
+
<TableHeader>
|
|
173
|
+
<TableRow>
|
|
174
|
+
<TableHead>CSV Column</TableHead>
|
|
175
|
+
<TableHead>Maps To</TableHead>
|
|
176
|
+
<TableHead className="w-24 text-center">Status</TableHead>
|
|
177
|
+
</TableRow>
|
|
178
|
+
</TableHeader>
|
|
179
|
+
<TableBody>
|
|
180
|
+
{headers.map((header, idx) => (
|
|
181
|
+
<TableRow key={idx}>
|
|
182
|
+
<TableCell className="font-medium">{header}</TableCell>
|
|
183
|
+
<TableCell>
|
|
184
|
+
<Select value={mapping[idx] ?? '__skip__'} onValueChange={(v) => handleChange(idx, v)}>
|
|
185
|
+
<SelectTrigger className="h-8 w-56"><SelectValue placeholder="Skip column" /></SelectTrigger>
|
|
186
|
+
<SelectContent>
|
|
187
|
+
<SelectItem value="__skip__">— Skip —</SelectItem>
|
|
188
|
+
{fields.map((f) => (
|
|
189
|
+
<SelectItem key={f.name} value={f.name} disabled={usedFields.has(f.name) && mapping[idx] !== f.name}>
|
|
190
|
+
{f.label}{f.required ? ' *' : ''}
|
|
191
|
+
</SelectItem>
|
|
192
|
+
))}
|
|
193
|
+
</SelectContent>
|
|
194
|
+
</Select>
|
|
195
|
+
</TableCell>
|
|
196
|
+
<TableCell className="text-center">
|
|
197
|
+
{mapping[idx]
|
|
198
|
+
? <Badge variant="default" className="text-xs">Mapped</Badge>
|
|
199
|
+
: <Badge variant="secondary" className="text-xs">Skipped</Badge>}
|
|
200
|
+
</TableCell>
|
|
201
|
+
</TableRow>
|
|
202
|
+
))}
|
|
203
|
+
</TableBody>
|
|
204
|
+
</Table>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Step 3: Preview & Import (shows first 10 rows with per-row validation errors)
|
|
210
|
+
const StepPreview: React.FC<{
|
|
211
|
+
headers: string[]; rows: string[][]; mapping: Record<number, string>; fields: ImportWizardProps['fields'];
|
|
212
|
+
}> = ({ headers, rows, mapping, fields }) => {
|
|
213
|
+
const mappedCols = useMemo(() =>
|
|
214
|
+
Object.entries(mapping).map(([idx, fieldName]) => ({
|
|
215
|
+
csvIdx: Number(idx), header: headers[Number(idx)], field: fields.find((f) => f.name === fieldName)!,
|
|
216
|
+
})), [mapping, headers, fields]);
|
|
217
|
+
const previewRows = rows.slice(0, PREVIEW_ROW_COUNT);
|
|
218
|
+
|
|
219
|
+
const rowValidations = useMemo(() => previewRows.map((row, _rIdx) => {
|
|
220
|
+
const errs: Record<number, string> = {};
|
|
221
|
+
for (const col of mappedCols) {
|
|
222
|
+
const raw = row[col.csvIdx] ?? '';
|
|
223
|
+
if (col.field.required && !raw) errs[col.csvIdx] = 'Required';
|
|
224
|
+
else if (raw && !validateValue(raw, col.field.type)) errs[col.csvIdx] = `Invalid ${col.field.type}`;
|
|
225
|
+
}
|
|
226
|
+
return errs;
|
|
227
|
+
}), [previewRows, mappedCols]);
|
|
228
|
+
|
|
229
|
+
const errorCount = rowValidations.filter(e => Object.keys(e).length > 0).length;
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div className="max-h-[360px] overflow-auto">
|
|
233
|
+
{errorCount > 0 && (
|
|
234
|
+
<p className="mb-2 flex items-center gap-1 text-xs text-destructive">
|
|
235
|
+
<AlertCircle className="h-3.5 w-3.5" /> {errorCount} row(s) with errors in preview
|
|
236
|
+
</p>
|
|
237
|
+
)}
|
|
238
|
+
<Table>
|
|
239
|
+
<TableHeader>
|
|
240
|
+
<TableRow>
|
|
241
|
+
<TableHead className="w-12">#</TableHead>
|
|
242
|
+
{mappedCols.map((col) => <TableHead key={col.csvIdx}>{col.field.label}</TableHead>)}
|
|
243
|
+
</TableRow>
|
|
244
|
+
</TableHeader>
|
|
245
|
+
<TableBody>
|
|
246
|
+
{previewRows.map((row, rIdx) => {
|
|
247
|
+
const errs = rowValidations[rIdx];
|
|
248
|
+
const hasError = Object.keys(errs).length > 0;
|
|
249
|
+
return (
|
|
250
|
+
<TableRow key={rIdx} className={cn(hasError && 'bg-destructive/5')}>
|
|
251
|
+
<TableCell className="text-xs text-muted-foreground">{rIdx + 1}</TableCell>
|
|
252
|
+
{mappedCols.map((col) => {
|
|
253
|
+
const value = row[col.csvIdx] ?? '';
|
|
254
|
+
const cellErr = errs[col.csvIdx];
|
|
255
|
+
return (
|
|
256
|
+
<TableCell key={col.csvIdx} className={cn(cellErr && 'text-destructive')} title={cellErr}>
|
|
257
|
+
{value || <span className="text-muted-foreground/50">—</span>}
|
|
258
|
+
</TableCell>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</TableRow>
|
|
262
|
+
);
|
|
263
|
+
})}
|
|
264
|
+
</TableBody>
|
|
265
|
+
</Table>
|
|
266
|
+
<p className="mt-2 text-xs text-muted-foreground">Showing {previewRows.length} of {rows.length} rows</p>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Main wizard component
|
|
272
|
+
export const ImportWizard: React.FC<ImportWizardProps> = ({
|
|
273
|
+
objectName, objectLabel, fields, dataSource, onComplete, onCancel, open, onOpenChange, onErrorMode = 'skip',
|
|
274
|
+
}) => {
|
|
275
|
+
const [step, setStep] = useState<WizardStep>('upload');
|
|
276
|
+
const [headers, setHeaders] = useState<string[]>([]);
|
|
277
|
+
const [rows, setRows] = useState<string[][]>([]);
|
|
278
|
+
const [mapping, setMapping] = useState<Record<number, string>>({});
|
|
279
|
+
const [importing, setImporting] = useState(false);
|
|
280
|
+
const [progress, setProgress] = useState(0);
|
|
281
|
+
const [result, setResult] = useState<ImportResult | null>(null);
|
|
282
|
+
const label = objectLabel ?? objectName;
|
|
283
|
+
|
|
284
|
+
const missingRequired = useMemo(() => {
|
|
285
|
+
const mapped = new Set(Object.values(mapping));
|
|
286
|
+
return fields.filter((f) => f.required && !mapped.has(f.name));
|
|
287
|
+
}, [fields, mapping]);
|
|
288
|
+
|
|
289
|
+
const handleFileLoaded = useCallback((h: string[], r: string[][]) => {
|
|
290
|
+
setHeaders(h); setRows(r); setMapping(autoMapColumns(h, fields)); setStep('mapping');
|
|
291
|
+
}, [fields]);
|
|
292
|
+
|
|
293
|
+
const handleImport = useCallback(async () => {
|
|
294
|
+
setImporting(true); setProgress(0);
|
|
295
|
+
const errors: ImportResult['errors'] = [];
|
|
296
|
+
let importedRows = 0, skippedRows = 0;
|
|
297
|
+
const mappedCols = Object.entries(mapping).map(([idx, name]) => ({
|
|
298
|
+
csvIdx: Number(idx), field: fields.find((f) => f.name === name)!,
|
|
299
|
+
}));
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < rows.length; i++) {
|
|
302
|
+
const { record, errors: rowErrors } = validateRow(rows[i], mappedCols, i + 1);
|
|
303
|
+
if (rowErrors.length > 0) {
|
|
304
|
+
skippedRows++;
|
|
305
|
+
errors.push(...rowErrors);
|
|
306
|
+
if (onErrorMode === 'stop') break;
|
|
307
|
+
} else {
|
|
308
|
+
try { if (dataSource?.create) await dataSource.create(objectName, record); importedRows++; }
|
|
309
|
+
catch (err) {
|
|
310
|
+
skippedRows++;
|
|
311
|
+
const msg = err instanceof Error ? err.message : 'Failed to create record';
|
|
312
|
+
errors.push({ row: i + 1, field: '', message: msg });
|
|
313
|
+
if (onErrorMode === 'stop') break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
setProgress(Math.round(((i + 1) / rows.length) * 100));
|
|
317
|
+
}
|
|
318
|
+
const importResult: ImportResult = { totalRows: rows.length, importedRows, skippedRows, errors };
|
|
319
|
+
setResult(importResult); setImporting(false); onComplete?.(importResult);
|
|
320
|
+
}, [rows, mapping, fields, dataSource, objectName, onComplete, onErrorMode]);
|
|
321
|
+
|
|
322
|
+
const reset = useCallback(() => {
|
|
323
|
+
setStep('upload'); setHeaders([]); setRows([]); setMapping({}); setProgress(0); setResult(null);
|
|
324
|
+
}, []);
|
|
325
|
+
|
|
326
|
+
const handleClose = useCallback(() => { reset(); onOpenChange?.(false); onCancel?.(); }, [reset, onOpenChange, onCancel]);
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange?.(v); }}>
|
|
330
|
+
<DialogContent className="sm:max-w-2xl">
|
|
331
|
+
<DialogHeader>
|
|
332
|
+
<DialogTitle className="flex items-center gap-2">
|
|
333
|
+
<FileSpreadsheet className="h-5 w-5" /> Import {label}
|
|
334
|
+
</DialogTitle>
|
|
335
|
+
<DialogDescription>
|
|
336
|
+
{step === 'upload' && 'Upload a CSV file to get started.'}
|
|
337
|
+
{step === 'mapping' && 'Map CSV columns to object fields.'}
|
|
338
|
+
{step === 'preview' && 'Review data before importing.'}
|
|
339
|
+
</DialogDescription>
|
|
340
|
+
</DialogHeader>
|
|
341
|
+
|
|
342
|
+
{/* Step indicators */}
|
|
343
|
+
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
344
|
+
{(['upload', 'mapping', 'preview'] as WizardStep[]).map((s, i) => (
|
|
345
|
+
<React.Fragment key={s}>
|
|
346
|
+
{i > 0 && <ArrowRight className="h-3 w-3" />}
|
|
347
|
+
<span className={cn('rounded-full px-3 py-1', step === s ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
|
|
348
|
+
{i + 1}. {s === 'upload' ? 'Upload' : s === 'mapping' ? 'Mapping' : 'Preview'}
|
|
349
|
+
</span>
|
|
350
|
+
</React.Fragment>
|
|
351
|
+
))}
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{!result ? (
|
|
355
|
+
<>
|
|
356
|
+
{step === 'upload' && <StepUpload onFileLoaded={handleFileLoaded} />}
|
|
357
|
+
{step === 'mapping' && <StepMapping headers={headers} fields={fields} mapping={mapping} onMappingChange={setMapping} />}
|
|
358
|
+
{step === 'preview' && <StepPreview headers={headers} rows={rows} mapping={mapping} fields={fields} />}
|
|
359
|
+
{importing && (
|
|
360
|
+
<div className="flex flex-col gap-1">
|
|
361
|
+
<Progress value={progress} className="h-2" />
|
|
362
|
+
<p className="text-center text-xs text-muted-foreground">Importing… {progress}%</p>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</>
|
|
366
|
+
) : (
|
|
367
|
+
<div className="flex flex-col items-center gap-3 py-4">
|
|
368
|
+
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
|
369
|
+
<p className="text-lg font-semibold">Import Complete</p>
|
|
370
|
+
<div className="flex gap-3">
|
|
371
|
+
<Badge variant="default">{result.importedRows} imported</Badge>
|
|
372
|
+
{result.skippedRows > 0 && <Badge variant="destructive">{result.skippedRows} skipped</Badge>}
|
|
373
|
+
</div>
|
|
374
|
+
{result.errors.length > 0 && (
|
|
375
|
+
<div className="max-h-32 w-full overflow-auto rounded border p-2 text-xs">
|
|
376
|
+
{result.errors.slice(0, 10).map((err, i) => (
|
|
377
|
+
<p key={i} className="text-destructive">Row {err.row}{err.field ? ` (${err.field})` : ''}: {err.message}</p>
|
|
378
|
+
))}
|
|
379
|
+
{result.errors.length > 10 && <p className="text-muted-foreground">…and {result.errors.length - 10} more errors</p>}
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
|
|
385
|
+
<DialogFooter className="gap-2 sm:gap-0">
|
|
386
|
+
{result ? (
|
|
387
|
+
<Button onClick={handleClose}>Close</Button>
|
|
388
|
+
) : (
|
|
389
|
+
<>
|
|
390
|
+
<Button variant="ghost" onClick={handleClose} disabled={importing}><X className="mr-1 h-4 w-4" /> Cancel</Button>
|
|
391
|
+
{(step === 'mapping' || step === 'preview') && (
|
|
392
|
+
<Button variant="outline" onClick={() => setStep(step === 'mapping' ? 'upload' : 'mapping')} disabled={importing}>
|
|
393
|
+
<ArrowLeft className="mr-1 h-4 w-4" /> Back
|
|
394
|
+
</Button>
|
|
395
|
+
)}
|
|
396
|
+
{step === 'mapping' && (
|
|
397
|
+
<Button onClick={() => setStep('preview')} disabled={Object.keys(mapping).length === 0 || missingRequired.length > 0}>
|
|
398
|
+
Next <ArrowRight className="ml-1 h-4 w-4" />
|
|
399
|
+
</Button>
|
|
400
|
+
)}
|
|
401
|
+
{step === 'preview' && (
|
|
402
|
+
<Button onClick={handleImport} disabled={importing}>
|
|
403
|
+
{importing ? 'Importing…' : `Import ${rows.length} Rows`}
|
|
404
|
+
</Button>
|
|
405
|
+
)}
|
|
406
|
+
</>
|
|
407
|
+
)}
|
|
408
|
+
</DialogFooter>
|
|
409
|
+
</DialogContent>
|
|
410
|
+
</Dialog>
|
|
411
|
+
);
|
|
412
|
+
};
|
|
@@ -151,10 +151,9 @@ describe('ListColumn: action', () => {
|
|
|
151
151
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
// Status cells should be buttons
|
|
155
|
-
const
|
|
156
|
-
expect(
|
|
157
|
-
expect(activeBtn[0]).toHaveClass('text-primary');
|
|
154
|
+
// Status cells should be buttons with action label
|
|
155
|
+
const actionBtns = screen.getAllByRole('button', { name: 'ToggleStatus' });
|
|
156
|
+
expect(actionBtns.length).toBeGreaterThanOrEqual(1);
|
|
158
157
|
});
|
|
159
158
|
|
|
160
159
|
it('should execute action when action column is clicked', async () => {
|
|
@@ -180,7 +179,7 @@ describe('ListColumn: action', () => {
|
|
|
180
179
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
181
180
|
});
|
|
182
181
|
|
|
183
|
-
const statusBtns = screen.getAllByRole('button', { name: '
|
|
182
|
+
const statusBtns = screen.getAllByRole('button', { name: 'ToggleStatus' });
|
|
184
183
|
fireEvent.click(statusBtns[0]);
|
|
185
184
|
|
|
186
185
|
await waitFor(() => {
|