UX Standards — 10: Import Wizard Modal
Governs: The 6-step data import wizard modal used across all list pages.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md and 11-MODALS-AND-DIALOGS.md first.
Overview
The Import Wizard is a 6-step modal that guides users through importing data from Excel/CSV/XML files. It is triggered by the Upload toolbar button (⬆ Import data) on any list page that has onImport wired.
Component: ImportDialog — src/components/import-dialog/import-dialog.tsx
Step Sequence
| Step | ID | Label | Progress |
|---|---|---|---|
| 1 | template | Download Template | 0% |
| 2 | upload | Upload File | 20% |
| 3 | mapping | Map Fields | 40% |
| 4 | options | Options | 60% |
| 5 | preview | Preview | 80% |
| 6 | confirm | Confirm | 100% |
Modal Container
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{title || `Import ${doctype}`}</DialogTitle>
<DialogDescription>{description || 'Import records from a file.'}</DialogDescription>
</DialogHeader>
{/* Progress bar */}
<div className="space-y-2 py-2">
<div className="flex justify-between text-sm">
<span className="font-medium">{stepTitle}</span>
<span className="text-muted-foreground">{stepIndex + 1} / 6</span>
</div>
<Progress value={stepProgress} />
</div>
{/* Step content — scrollable */}
<div className="flex-1 overflow-y-auto py-4">
{/* Step component renders here */}
</div>
</DialogContent>
</Dialog>
| Property | Value |
|---|---|
| Max width | max-w-4xl (896px) |
| Max height | max-h-[90vh] with overflow-hidden |
| Layout | flex flex-col — header + progress fixed, content scrolls |
| Content | flex-1 overflow-y-auto py-4 |
Progress Bar
<div className="space-y-2 py-2">
<div className="flex justify-between text-sm">
<span className="font-medium">{currentStepTitle}</span>
<span className="text-muted-foreground">{currentStepNumber} / 6</span>
</div>
<Progress value={stepProgress} /> {/* shadcn Progress component */}
</div>
The shadcn Progress component fills left-to-right based on the value (0–100). No custom progress bar — use @/components/ui/progress.
Step 1 — Download Template
Purpose: User downloads the correct import template before preparing their file.
┌──────────────────────────────────────────────────────┐
│ Download a template to ensure your data is in the │
│ correct format before importing. │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 📊 Excel │ │ 📄 CSV │ │ 📋 XML │ │
│ │ .xlsx │ │ .csv │ │ .xml │ │
│ │[Download] │ │[Download] │ │[Download] │ │
│ └───────────── ┘ └─────────────┘ └─────────────┘ │
│ │
│ Machine-Readable Formats │
│ ┌──────────────────────────────────────────────┐ │
│ │ 📄 JSON 🗃 TSV ⎜ Pipe-delimited │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ Required Fields Optional Fields │
│ • Name * • Description │
│ • Email * • Phone │
│ • Role * • Department │
│ │
│ [Next: Upload File →] │
└──────────────────────────────────────────────────────┘
Key elements:
- 3 primary download format cards (Excel, CSV, XML) in
grid-cols-3 gap-4 - Machine-readable section: JSON, TSV, Pipe-delimited
- Required/Optional field lists (2-column grid)
- Only "Next" CTA (no Back on Step 1)
Step 2 — Upload File
Purpose: User uploads their prepared file. Shows parse results after upload.
Upload Zone (no file selected)
<div
className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border p-12 text-center hover:border-primary/50 transition-colors"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
<Upload className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm font-medium">Drop your file here, or</p>
<Button variant="link" onClick={openFilePicker}>browse to upload</Button>
<p className="text-xs text-muted-foreground mt-2">
Supports Excel (.xlsx), CSV (.csv), XML (.xml), JSON (.json), TSV (.tsv)
</p>
</div>
After Upload (file parsed)
<div className="space-y-4">
{/* File metadata */}
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">File</p>
<p className="font-medium">{file.name}</p>
</div>
<div>
<p className="text-muted-foreground">Size</p>
<p className="font-medium">{formatFileSize(file.size)}</p>
</div>
<div>
<p className="text-muted-foreground">Rows</p>
<p className="font-medium">{parsedData.totalRows.toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Columns</p>
<p className="font-medium">{parsedData.headers.length}</p>
</div>
</div>
{parsedData.parseWarnings > 0 && (
<Alert className="mt-3" variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{parsedData.parseWarnings} row(s) had parse warnings and will be skipped.
</AlertDescription>
</Alert>
)}
</div>
{/* Data preview table */}
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted">
{parsedData.headers.map(h => <th className="px-3 py-2 text-left">{h}</th>)}
</thead>
<tbody>
{parsedData.previewRows.slice(0, 5).map((row, i) => (
<tr key={i} className="border-t">
{row.map((cell, j) => <td key={j} className="px-3 py-1.5 truncate max-w-[150px]">{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</div>
CTAs: [← Back] [Next: Map Fields →]
Step 3 — Map Fields
Purpose: User maps source file columns to destination DocType fields.
┌──────────────────────────────────────────────────────┐
│ Map your file columns to the correct fields. │
│ [Auto-Map →] │
│ │
│ File Column → Target Field │
│ ─────────────────────────────────────────────────── │
│ full_name → [Name (required) ▼] ✓│
│ email_address → [Email Address (req) ▼] ✓│
│ job_title → [Role (required) ▼] │
│ mobile → [Phone Number (opt) ▼] │
│ notes → [— Skip this column — ▼] │
│ │
│ ⚠ Required fields not mapped: Role │
│ │
│ [← Back] [Next: Options →] │
└──────────────────────────────── ──────────────────────┘
| Element | Spec |
|---|---|
| Auto-Map link | Attempts to match source headers to target fields by name similarity |
| Required field indicator | (required) in label — shown in Select options |
| Mapped indicator | ✓ green checkmark (text-primary) when field is mapped |
| Unmapped required | Orange Alert banner: "Required fields not mapped: [list]" |
| Skip option | First Select option: "— Skip this column —" |
| Target Select | h-8 Select with all available DocType fields |
Error state (required field unmapped):
{hasUnmappedRequired && (
<Alert className="bg-orange-50 border-orange-200 text-orange-800 dark:bg-orange-950/30">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Required fields not mapped: {unmappedRequired.join(', ')}
</AlertDescription>
</Alert>
)}
The "Next" button is disabled when required fields are not mapped.
Step 4 — Options
Purpose: Configure how records are imported (create vs update, deduplication).
┌──────────────────────────────────────────────────────┐
│ Configure import behavior. │
│ │
│ Import Mode │
│ ○ Insert new records only │
│ ● Insert and update existing records │
│ ○ Update existing records only │
│ │
│ Update Key (when updating) │
│ [Email Address ▼] │
│ │
│ Additional Options │
│ ☐ Skip rows with errors │
│ ☑ Send notification emails on import │
│ ☐ Submit records after import │
│ │
│ [← Back] [Next: Preview →] │
└──────────────────────────────────────────────────────┘
| Element | Spec |
|---|---|
| Import Mode | RadioGroup — 3 options |
| Update Key | Select — which field to match on for updates |
| Options | Checkbox items with labels |
| Back/Next | Standard footer CTAs |
Step 5 — Preview
Purpose: Validate the data before import. Shows error/warning rows.
┌──────────────────────────────────────────────────────┐
│ Review the data before importing. │
│ │
│ ✓ 147 rows ready to import │
│ ⚠ 3 rows have warnings (will be imported) │
│ ✕ 5 rows have errors (will be skipped) │
│ │
│ [Show: All ▼] [Filter: Errors only ▼] │
│ │
│ ┌────┬────────────┬───────────┬──────────────────┐ │
│ │ # │ Name │ Status │ Issue │ │
│ ├────┼────────────┼───────────┼──────────────────┤ │
│ │ 1 │ Jane Smith │ ✓ Ready │ │ │
│ │ 2 │ John Doe │ ⚠ Warning │ Duplicate email │ │
│ │ 3 │ │ ✕ Error │ Name is required │ │
│ └────┴────────────┴───────────┴──────────────────┘ │
│ │
│ [← Back] [Next: Confirm →] │
└──────────────────────────────────────────────────────┘
Row status colors:
- Ready:
text-primarywithCheckCircleicon - Warning:
text-yellow-600(amber) withAlertTriangleicon - Error:
text-destructivewithXCircleicon
Step 6 — Confirm
Purpose: Final summary and commit.
┌──────────────────────────────────────────────────────┐
│ Ready to import. Review the summary below. │
│ │
│ File: employees_2025.xlsx │
│ Format: Excel │
│ Mode: Insert and update existing records │
│ Update Key: Email Address │
│ │
│ Import Summary │
│ ┌──────────────────────────────────────────────┐ │
│ │ 147 records will be created │ │
│ │ 12 records will be updated │ │
│ │ 5 records will be skipped (errors) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ [← Back] [⬆ Import 159 Records] │
└──────────────────────────────────────────────────────┘
During import:
// Confirm button shows loading state:
<Button disabled={isProcessing}>
{isProcessing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Importing...
</>
) : (
`Import ${readyCount} Records`
)}
</Button>
On success: toast.success('Import complete', { description: 'X records imported.' }) and dialog closes.
On failure: toast.error('Import failed', { description: errorMessage }) and dialog stays open.
CTA (Call-to-Action) Pattern
All steps follow the same footer CTA layout:
// Inside each step component:
<div className="flex justify-between mt-6 pt-4 border-t">
{showBack && (
<Button variant="outline" onClick={onBack}>
<ChevronLeft className="mr-2 h-4 w-4" />
Back
</Button>
)}
<div className="flex-1" />
<Button onClick={onNext} disabled={!canProceed}>
{isLastStep ? 'Import' : 'Next'}
{!isLastStep && <ChevronRight className="ml-2 h-4 w-4" />}
</Button>
</div>
| CTA | Step 1 | Steps 2–5 | Step 6 |
|---|---|---|---|
| Back | Hidden | [← Back] outline | [← Back] outline |
| Primary | [Next →] | [Next →] | [⬆ Import N Records] |
| Primary disabled | — | When required unmapped (Step 3) | When processing |
Integration: Triggering the Import Dialog
// In a list page:
const [importOpen, setImportOpen] = useState(false);
<DataTableToolbar
table={table}
onImport={() => setImportOpen(true)}
permissions={{ canImport: true }}
/>
<ImportDialog
open={importOpen}
onOpenChange={setImportOpen}
doctype="User"
title="Import Users"
onComplete={(result) => {
refetch(); // Refresh the table after import
toast.success(`Imported ${result.imported} users`);
}}
/>
Violation Checklist
- Modal uses
max-w-4xl max-h-[90vh] overflow-hidden flex flex-col - Progress bar uses shadcn
Progresscomponent (not custom) - Step counter shows "N / 6" in
text-muted-foreground - Upload zone uses
border-2 border-dashed border-borderwith hover state - File metadata shown in
rounded-lg border bg-card p-4card - Required field unmapped error uses orange
Alertbanner - Back button uses
variant="outline"withChevronLeft - Next button is primary (default variant) with
ChevronRight - Step 6 primary CTA says "Import N Records"
- Processing state shows
animate-spinonRefreshCwicon - Success uses
toast.success, failure usestoast.error - Dialog closes on success, stays open on failure