Import wizard
Adds records from a spreadsheet in one dialog: choose the file, pick the header row, match columns, preview each row, then import with Undo for 14 days.
Playground
Each row's outcome is a lozenge. Change the control to see each; the code lists the prop.
import { ImportOutcome } from '@/ui/import-wizard';<ImportOutcome />The whole flow
Press Import and drop any file: the sample walks through the header row, matching (a passport column is left out as sensitive), the preview's dry run, and the import. One row has a problem, so the last step offers Download rows with problems next to Undo import, which asks first.
import { UploadSimpleIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button } from '@/ui/button';
import {
type ImportColumn,
ImportDoneStep,
ImportFileStep,
ImportHeaderStep,
ImportMatchStep,
ImportPreviewStep,
ImportWizard,
} from '@/ui/import-wizard';
import { toast } from '@/ui/toast';
const noun = { one: 'candidate', many: 'candidates' };
const fields = [
{ key: 'full_name', label: 'Full name', required: true },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'current_role', label: 'Current role' },
];
const header = [
{ line: 1, cells: ['Northbridge Talent · Candidate export · 14 Oct 2026'] },
{ line: 2, cells: ['Name', 'Email', 'Current role', 'Notes', 'Passport number'] },
{ line: 3, cells: ['Maya Chen', 'maya.chen@halcyonbank.com', 'Senior data engineer', 'Met at PyData', 'X1234567'] },
];
const startColumns: ImportColumn[] = [
{ header: 'Name', firstValue: 'Maya Chen', field: 'full_name' },
{ header: 'Email', firstValue: 'maya.chen@halcyonbank.com', field: 'email' },
{ header: 'Current role', firstValue: 'Senior data engineer', field: 'current_role' },
{ header: 'Notes', firstValue: 'Met at PyData', field: null },
{ header: 'Passport number', field: null, sensitive: true },
];
const results = [
{ row: 3, name: 'Maya Chen', outcome: 'created' as const },
{ row: 4, name: 'Daniel Okafor', outcome: 'updated' as const, detail: 'Fills Phone.' },
{ row: 5, name: 'Priya Shah', outcome: 'skipped' as const, detail: 'Already in Ascern.' },
{ row: 6, name: 'Tom Lindqvist', outcome: 'failed' as const, detail: 'Check the email in this row.' },
{ row: 7, name: 'Nguyễn Thị Minh Khai', outcome: 'created' as const },
];
const counts = { created: 12, updated: 3, skipped: 1, failed: 1 };
type Step = 'file' | 'header' | 'match' | 'preview' | 'done';
// The whole flow with sample data: drop any file, then step through to the import and Undo.
export default function ImportWizardFlow() {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<Step>('file');
const [file, setFile] = useState<string>();
const [headerIndex, setHeaderIndex] = useState(1);
const [columns, setColumns] = useState(startColumns);
const [onMatch, setOnMatch] = useState<'skip' | 'fill'>('skip');
const [pending, setPending] = useState(false);
const [undo, setUndo] = useState<{ pending?: boolean; done?: string }>();
const later = (next: () => void) => {
setPending(true);
setTimeout(() => {
setPending(false);
next();
}, 700);
};
const missingName = !columns.some((column) => column.field === 'full_name');
return (
<>
<Button
onClick={() => {
setStep('file');
setFile(undefined);
setUndo(undefined);
setOpen(true);
}}
>
<UploadSimpleIcon />
Import
</Button>
<ImportWizard open={open} onOpenChange={setOpen} noun={noun}>
{step === 'file' ? (
<ImportFileStep
fileName={file}
canContinue={Boolean(file)}
onFile={(chosen) => setFile(chosen.name)}
onPaste={() => setStep('header')}
pastePlaceholder={'Full name,Email,Current role\nPhạm Thị Lan,lan@example.com,Product Manager'}
onDownloadTemplate={() => toast.add({ title: 'ascern-candidates-template.csv downloaded' })}
onNext={() => setStep('header')}
onCancel={() => setOpen(false)}
/>
) : null}
{step === 'header' ? <ImportHeaderStep rows={header} value={headerIndex} onValueChange={setHeaderIndex} onBack={() => setStep('file')} onNext={() => setStep('match')} /> : null}
{step === 'match' ? (
<ImportMatchStep
columns={columns}
fields={fields}
onFieldChange={(index, field) => setColumns(columns.map((column, other) => (other === index ? { ...column, field } : column.field === field ? { ...column, field: null } : column)))}
onMatch={onMatch}
onOnMatchChange={setOnMatch}
matchHint="When a person is already in Ascern (same email, phone or LinkedIn)"
problem={missingName ? 'Choose the column for Full name.' : undefined}
pending={pending}
onBack={() => setStep('header')}
onPreview={() => later(() => setStep('preview'))}
/>
) : null}
{step === 'preview' ? (
<ImportPreviewStep
noun={noun}
counts={counts}
results={results}
nameLabel="Full name"
note="These people's emails can now come into members' Inboxes, depending on each member's setting."
pending={pending}
onBack={() => setStep('match')}
onImport={() => later(() => setStep('done'))}
/>
) : null}
{step === 'done' ? (
<ImportDoneStep
fileName={file ?? 'pasted.csv'}
counts={counts}
undo={undo}
onUndo={() => {
setUndo({ pending: true });
setTimeout(() => setUndo({ done: 'Import undone.' }), 700);
}}
onDownloadProblems={() => toast.add({ title: 'Rows with problems downloaded', description: '1 row, with an Error column saying what to fix.' })}
onDone={() => setOpen(false)}
/>
) : null}
</ImportWizard>
</>
);
}A file that can't be used
Choose any file: the step says what's wrong and what to do, and Next stays off. Limits are stated before the choice.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { ImportFileStep, ImportWizard } from '@/ui/import-wizard';
// A file over the limit is refused before anything happens, with what to do instead.
export default function ImportWizardFileProblems() {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string>();
return (
<>
<Button onClick={() => setOpen(true)}>Import companies</Button>
<ImportWizard open={open} onOpenChange={setOpen} noun={{ one: 'company', many: 'companies' }}>
<ImportFileStep
error={error}
onFile={(file) => setError(file.size > 1_000_000 ? 'Use a CSV or Excel file of 1 MB or less.' : `Include a header row and at least one company. ${file.name} has none.`)}
onNext={() => {}}
onCancel={() => setOpen(false)}
/>
</ImportWizard>
</>
);
}Nothing to import
Every row is skipped or has a problem: the preview says so on each row and the button says there is nothing to import.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { ImportPreviewStep, ImportWizard } from '@/ui/import-wizard';
// Every row is already in Ascern or has a problem: the preview says so and there is nothing to import.
export default function ImportWizardNothingToImport() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Import contacts</Button>
<ImportWizard open={open} onOpenChange={setOpen} noun={{ one: 'contact', many: 'contacts' }}>
<ImportPreviewStep
noun={{ one: 'contact', many: 'contacts' }}
counts={{ created: 0, updated: 0, skipped: 2, failed: 1 }}
nameLabel="Full name"
results={[
{ row: 2, name: 'Hannah Moss', outcome: 'skipped', detail: 'Already a contact at Northwind Logistics.' },
{ row: 3, name: 'Kwame Asante', outcome: 'skipped', detail: 'Already a contact at Halcyon Bank.' },
{ row: 4, name: '', outcome: 'failed', detail: 'Add a name or an email to this row.' },
]}
error="Nothing to import: every row is skipped or has a problem. Fix the file and try again."
onBack={() => {}}
onImport={() => {}}
/>
</ImportWizard>
</>
);
}