Skip to main content

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: ImportDialogsrc/components/import-dialog/import-dialog.tsx


Step Sequence

StepIDLabelProgress
1templateDownload Template0%
2uploadUpload File20%
3mappingMap Fields40%
4optionsOptions60%
5previewPreview80%
6confirmConfirm100%

<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>
PropertyValue
Max widthmax-w-4xl (896px)
Max heightmax-h-[90vh] with overflow-hidden
Layoutflex flex-col — header + progress fixed, content scrolls
Contentflex-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 →] │
└──────────────────────────────────────────────────────┘
ElementSpec
Auto-Map linkAttempts 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 requiredOrange Alert banner: "Required fields not mapped: [list]"
Skip optionFirst Select option: "— Skip this column —"
Target Selecth-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 →] │
└──────────────────────────────────────────────────────┘
ElementSpec
Import ModeRadioGroup — 3 options
Update KeySelect — which field to match on for updates
OptionsCheckbox items with labels
Back/NextStandard 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-primary with CheckCircle icon
  • Warning: text-yellow-600 (amber) with AlertTriangle icon
  • Error: text-destructive with XCircle icon

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>
CTAStep 1Steps 2–5Step 6
BackHidden[← Back] outline[← Back] outline
Primary[Next →][Next →][⬆ Import N Records]
Primary disabledWhen 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 Progress component (not custom)
  • Step counter shows "N / 6" in text-muted-foreground
  • Upload zone uses border-2 border-dashed border-border with hover state
  • File metadata shown in rounded-lg border bg-card p-4 card
  • Required field unmapped error uses orange Alert banner
  • Back button uses variant="outline" with ChevronLeft
  • Next button is primary (default variant) with ChevronRight
  • Step 6 primary CTA says "Import N Records"
  • Processing state shows animate-spin on RefreshCw icon
  • Success uses toast.success, failure uses toast.error
  • Dialog closes on success, stays open on failure