ComponentsInputs and forms

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.

New
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>
    </>
  );
}

On this page