@object-ui/plugin-grid 3.3.0 → 3.3.2
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/CHANGELOG.md +23 -0
- package/README.md +21 -1
- package/dist/index.js +631 -599
- package/dist/index.umd.cjs +8 -8
- package/package.json +44 -12
- package/.turbo/turbo-build.log +0 -32
- package/src/FormulaBar.tsx +0 -151
- package/src/GroupRow.tsx +0 -69
- package/src/ImportWizard.tsx +0 -412
- package/src/InlineEditing.tsx +0 -235
- package/src/ListColumnExtensions.test.tsx +0 -373
- package/src/ListColumnSchema.test.ts +0 -88
- package/src/ObjectGrid.EdgeCases.stories.tsx +0 -147
- package/src/ObjectGrid.msw.test.tsx +0 -130
- package/src/ObjectGrid.stories.tsx +0 -139
- package/src/ObjectGrid.tsx +0 -1598
- package/src/SplitPaneGrid.tsx +0 -120
- package/src/VirtualGrid.tsx +0 -183
- package/src/__tests__/GroupRow.test.tsx +0 -206
- package/src/__tests__/ImportPreview.test.tsx +0 -171
- package/src/__tests__/InlineEditing.test.tsx +0 -360
- package/src/__tests__/VirtualGrid.test.tsx +0 -438
- package/src/__tests__/accessibility.test.tsx +0 -254
- package/src/__tests__/accessorKey-inference.test.tsx +0 -132
- package/src/__tests__/airtable-style.test.tsx +0 -508
- package/src/__tests__/column-features.test.tsx +0 -490
- package/src/__tests__/grid-export.test.tsx +0 -121
- package/src/__tests__/mobile-card-view.test.tsx +0 -355
- package/src/__tests__/objectdef-enrichment.test.tsx +0 -566
- package/src/__tests__/performance-benchmark.test.tsx +0 -182
- package/src/__tests__/phase11-features.test.tsx +0 -418
- package/src/__tests__/row-bulk-actions.test.tsx +0 -413
- package/src/__tests__/row-height.test.tsx +0 -160
- package/src/__tests__/useGroupedData.test.ts +0 -165
- package/src/__tests__/view-states.test.tsx +0 -203
- package/src/components/BulkActionBar.tsx +0 -66
- package/src/components/RowActionMenu.tsx +0 -91
- package/src/index.test.tsx +0 -29
- package/src/index.tsx +0 -99
- package/src/useCellClipboard.ts +0 -136
- package/src/useColumnSummary.ts +0 -128
- package/src/useGradientColor.ts +0 -103
- package/src/useGroupReorder.ts +0 -123
- package/src/useGroupedData.ts +0 -187
- package/src/useRowColor.ts +0 -74
- package/tsconfig.json +0 -9
- package/vite.config.ts +0 -58
- package/vitest.config.ts +0 -13
- package/vitest.setup.ts +0 -1
package/src/FormulaBar.tsx
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
10
|
-
import { cn } from '@object-ui/components';
|
|
11
|
-
import { FunctionSquare, Check, X } from 'lucide-react';
|
|
12
|
-
|
|
13
|
-
export interface FormulaBarProps {
|
|
14
|
-
/** Current cell value displayed in the bar. */
|
|
15
|
-
value: string;
|
|
16
|
-
/** Called when the user edits the value (controlled input). */
|
|
17
|
-
onChange?: (value: string) => void;
|
|
18
|
-
/** Called when the user confirms the edit (Enter or check button). */
|
|
19
|
-
onConfirm?: (value: string) => void;
|
|
20
|
-
/** Called when the user cancels the edit (Escape or X button). */
|
|
21
|
-
onCancel?: () => void;
|
|
22
|
-
/** Label describing the active cell, e.g. "A1" or "name". */
|
|
23
|
-
activeCell?: string;
|
|
24
|
-
/** Whether editing is disabled. */
|
|
25
|
-
disabled?: boolean;
|
|
26
|
-
/** Additional class names. */
|
|
27
|
-
className?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Excel-like formula bar that displays and allows editing of the active cell's
|
|
32
|
-
* value. Press Enter (or the ✓ button) to confirm, Escape (or the ✗ button)
|
|
33
|
-
* to cancel.
|
|
34
|
-
*/
|
|
35
|
-
export function FormulaBar({
|
|
36
|
-
value,
|
|
37
|
-
onChange,
|
|
38
|
-
onConfirm,
|
|
39
|
-
onCancel,
|
|
40
|
-
activeCell,
|
|
41
|
-
disabled = false,
|
|
42
|
-
className,
|
|
43
|
-
}: FormulaBarProps) {
|
|
44
|
-
const [editing, setEditing] = useState(false);
|
|
45
|
-
const [editValue, setEditValue] = useState(value);
|
|
46
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
47
|
-
|
|
48
|
-
// Sync external value when not editing.
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (!editing) {
|
|
51
|
-
setEditValue(value);
|
|
52
|
-
}
|
|
53
|
-
}, [value, editing]);
|
|
54
|
-
|
|
55
|
-
// Auto-focus on edit start.
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
if (editing && inputRef.current) {
|
|
58
|
-
inputRef.current.focus();
|
|
59
|
-
inputRef.current.select();
|
|
60
|
-
}
|
|
61
|
-
}, [editing]);
|
|
62
|
-
|
|
63
|
-
const startEditing = useCallback(() => {
|
|
64
|
-
if (disabled) return;
|
|
65
|
-
setEditing(true);
|
|
66
|
-
setEditValue(value);
|
|
67
|
-
}, [disabled, value]);
|
|
68
|
-
|
|
69
|
-
const confirm = useCallback(() => {
|
|
70
|
-
setEditing(false);
|
|
71
|
-
onChange?.(editValue);
|
|
72
|
-
onConfirm?.(editValue);
|
|
73
|
-
}, [editValue, onChange, onConfirm]);
|
|
74
|
-
|
|
75
|
-
const cancel = useCallback(() => {
|
|
76
|
-
setEditing(false);
|
|
77
|
-
setEditValue(value);
|
|
78
|
-
onCancel?.();
|
|
79
|
-
}, [value, onCancel]);
|
|
80
|
-
|
|
81
|
-
const handleKeyDown = useCallback(
|
|
82
|
-
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
83
|
-
if (e.key === 'Enter') {
|
|
84
|
-
e.preventDefault();
|
|
85
|
-
confirm();
|
|
86
|
-
} else if (e.key === 'Escape') {
|
|
87
|
-
e.preventDefault();
|
|
88
|
-
cancel();
|
|
89
|
-
}
|
|
90
|
-
},
|
|
91
|
-
[confirm, cancel],
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
<div
|
|
96
|
-
className={cn(
|
|
97
|
-
'flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-1.5',
|
|
98
|
-
className,
|
|
99
|
-
)}
|
|
100
|
-
>
|
|
101
|
-
{/* f(x) indicator */}
|
|
102
|
-
<FunctionSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
103
|
-
|
|
104
|
-
{/* Active cell label */}
|
|
105
|
-
{activeCell && (
|
|
106
|
-
<span className="min-w-[4rem] shrink-0 rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
|
107
|
-
{activeCell}
|
|
108
|
-
</span>
|
|
109
|
-
)}
|
|
110
|
-
|
|
111
|
-
{/* Value input */}
|
|
112
|
-
<input
|
|
113
|
-
ref={inputRef}
|
|
114
|
-
type="text"
|
|
115
|
-
value={editing ? editValue : value}
|
|
116
|
-
readOnly={!editing}
|
|
117
|
-
disabled={disabled}
|
|
118
|
-
onClick={startEditing}
|
|
119
|
-
onChange={(e) => setEditValue(e.target.value)}
|
|
120
|
-
onKeyDown={handleKeyDown}
|
|
121
|
-
className={cn(
|
|
122
|
-
'flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
|
123
|
-
editing && 'rounded ring-1 ring-ring px-1',
|
|
124
|
-
disabled && 'cursor-not-allowed opacity-50',
|
|
125
|
-
)}
|
|
126
|
-
/>
|
|
127
|
-
|
|
128
|
-
{/* Confirm / Cancel buttons (visible only while editing) */}
|
|
129
|
-
{editing && (
|
|
130
|
-
<div className="flex items-center gap-1">
|
|
131
|
-
<button
|
|
132
|
-
type="button"
|
|
133
|
-
onClick={confirm}
|
|
134
|
-
className="rounded p-0.5 text-green-600 hover:bg-green-100"
|
|
135
|
-
aria-label="Confirm"
|
|
136
|
-
>
|
|
137
|
-
<Check className="h-4 w-4" />
|
|
138
|
-
</button>
|
|
139
|
-
<button
|
|
140
|
-
type="button"
|
|
141
|
-
onClick={cancel}
|
|
142
|
-
className="rounded p-0.5 text-red-600 hover:bg-red-100"
|
|
143
|
-
aria-label="Cancel"
|
|
144
|
-
>
|
|
145
|
-
<X className="h-4 w-4" />
|
|
146
|
-
</button>
|
|
147
|
-
</div>
|
|
148
|
-
)}
|
|
149
|
-
</div>
|
|
150
|
-
);
|
|
151
|
-
}
|
package/src/GroupRow.tsx
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React from 'react';
|
|
10
|
-
import { ChevronRight, ChevronDown } from 'lucide-react';
|
|
11
|
-
import type { AggregationResult } from './useGroupedData';
|
|
12
|
-
|
|
13
|
-
export interface GroupRowProps {
|
|
14
|
-
/** Unique key identifying this group */
|
|
15
|
-
groupKey: string;
|
|
16
|
-
/** Display label for the group (field value or "(empty)") */
|
|
17
|
-
label: string;
|
|
18
|
-
/** Number of rows in this group */
|
|
19
|
-
count: number;
|
|
20
|
-
/** Whether the group is collapsed */
|
|
21
|
-
collapsed: boolean;
|
|
22
|
-
/** Computed aggregation results for this group */
|
|
23
|
-
aggregations?: AggregationResult[];
|
|
24
|
-
/** Callback when the group header is clicked to toggle collapse */
|
|
25
|
-
onToggle: (key: string) => void;
|
|
26
|
-
/** Children to render when not collapsed (the group content) */
|
|
27
|
-
children: React.ReactNode;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* GroupRow renders a collapsible group header with field value, record count,
|
|
32
|
-
* and optional aggregation summary. Used by ObjectGrid for grouped rendering.
|
|
33
|
-
*/
|
|
34
|
-
export const GroupRow: React.FC<GroupRowProps> = ({
|
|
35
|
-
groupKey,
|
|
36
|
-
label,
|
|
37
|
-
count,
|
|
38
|
-
collapsed,
|
|
39
|
-
aggregations,
|
|
40
|
-
onToggle,
|
|
41
|
-
children,
|
|
42
|
-
}) => {
|
|
43
|
-
return (
|
|
44
|
-
<div className="border rounded-md" data-testid={`group-row-${groupKey}`}>
|
|
45
|
-
<button
|
|
46
|
-
type="button"
|
|
47
|
-
className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
|
|
48
|
-
onClick={() => onToggle(groupKey)}
|
|
49
|
-
aria-expanded={!collapsed}
|
|
50
|
-
>
|
|
51
|
-
{collapsed
|
|
52
|
-
? <ChevronRight className="h-4 w-4 shrink-0" />
|
|
53
|
-
: <ChevronDown className="h-4 w-4 shrink-0" />}
|
|
54
|
-
<span className="group-label">{label}</span>
|
|
55
|
-
{aggregations && aggregations.length > 0 && (
|
|
56
|
-
<span className="ml-2 text-xs text-muted-foreground group-aggregations">
|
|
57
|
-
{aggregations.map((agg) => (
|
|
58
|
-
<span key={`${agg.field}-${agg.type}`} className="mr-2">
|
|
59
|
-
{agg.type}: {Number.isInteger(agg.value) ? agg.value : agg.value.toFixed(2)}
|
|
60
|
-
</span>
|
|
61
|
-
))}
|
|
62
|
-
</span>
|
|
63
|
-
)}
|
|
64
|
-
<span className="ml-auto text-xs text-muted-foreground group-count">({count})</span>
|
|
65
|
-
</button>
|
|
66
|
-
{!collapsed && children}
|
|
67
|
-
</div>
|
|
68
|
-
);
|
|
69
|
-
};
|
package/src/ImportWizard.tsx
DELETED
|
@@ -1,412 +0,0 @@
|
|
|
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
|
-
};
|