Import Wizard
blockdevopsCSV import flow in a SectionCard: four-step state machine (Upload → Map fields → Preview → Result) with an interactive Stepper, auto-mapped column targets carrying confidence badges, required-field validation that gates the footer action, flagged-cell highlighting in the preview table, and a success/partial result screen. Data is stubbed post-parse — wire the file reader of your choice.
Installation
$pnpm dlx uipkge-ng@latest add import-wizard$npx uipkge-ng@latest add import-wizard$yarn dlx uipkge-ng@latest add import-wizard$bunx uipkge-ng@latest add import-wizardManifest:
https://uipkge.dev/r/angular/import-wizard.jsonInstalls to:src/app/components/blocks/import-wizard/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CsvRowinterface CsvRow {
full_name: string
email_address: string
dept: string
job_title: string
start_date: string
manager: string
}npm dependencies
Files installed (2)
src/app/components/blocks/import-wizard/import-wizard.component.ts13 kB
// CSV import flow in a SectionCard: four-step state machine (Upload → Map fields → // Preview → Result) with an interactive Stepper, auto-mapped column targets carrying // confidence badges, required-field validation that gates the footer action, flagged-cell // highlighting in the preview table, and a success/partial result screen. Data is stubbed // post-parse — wire the file reader of your choice. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, OnInit, computed, signal } from '@angular/core' import { CheckCircle2, ChevronLeft, ChevronRight, FileSpreadsheet, LucideAngularModule, Upload } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAlertComponent, UiAlertDescriptionComponent, UiAlertTitleComponent } from '@/ui/alert/alert.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiSectionCardComponent } from '@/ui/section-card/section-card.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiStepperComponent, type StepperStep } from '@/ui/stepper/stepper.component' export type TargetField = 'Full name' | 'Email' | 'Department' | 'Job title' | 'Start date' | 'Skip' interface CsvRow { full_name: string email_address: string dept: string job_title: string start_date: string manager: string } type CsvColumn = keyof CsvRow const steps: StepperStep[] = [ { id: 1, title: 'Upload' }, { id: 2, title: 'Map fields' }, { id: 3, title: 'Preview' }, { id: 4, title: 'Result' }, ] const columns: CsvColumn[] = ['full_name', 'email_address', 'dept', 'job_title', 'start_date', 'manager'] const defaultMapping: Record<CsvColumn, TargetField> = { full_name: 'Full name', email_address: 'Email', dept: 'Department', job_title: 'Job title', start_date: 'Start date', manager: 'Skip', } const confidence: Record<CsvColumn, 'high' | 'medium'> = { full_name: 'high', email_address: 'high', dept: 'medium', job_title: 'high', start_date: 'medium', manager: 'high', } const targetOptions: TargetField[] = ['Full name', 'Email', 'Department', 'Job title', 'Start date', 'Skip'] const requiredTargets: TargetField[] = ['Full name', 'Email'] const rows: CsvRow[] = [ { full_name: 'Amara Osei', email_address: '[email protected]', dept: 'Engineering', job_title: 'Backend engineer', start_date: '2026-09-01', manager: '[email protected]', }, { full_name: 'Marcus Lee', email_address: '[email protected]', dept: 'Design', job_title: 'Product designer', start_date: '2026-09-01', manager: '[email protected]', }, { full_name: '', email_address: '[email protected]', dept: 'Sales', job_title: 'Account exec', start_date: '2026-09-08', manager: '[email protected]', }, { full_name: 'Priya Nair', email_address: 'not-an-email', dept: 'People', job_title: 'Recruiter', start_date: '2026-09-15', manager: '[email protected]', }, { full_name: 'Jonas Weber', email_address: '[email protected]', dept: 'Engineering', job_title: 'Eng manager', start_date: '2026-08-24', manager: '', }, ] const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-import-wizard, [ui-import-wizard]', standalone: true, // React renders the SectionCard itself: the host stays out of layout and `class` goes to the card. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiAlertComponent, UiAlertDescriptionComponent, UiAlertTitleComponent, UiBadgeComponent, UiButtonComponent, UiSectionCardComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiStepperComponent, ], template: ` <ui-section-card data-slot="import-wizard" [title]="'Import employees'" [description]="'Bring your people data in as CSV — map, review, then commit.'" [class]="className" > <ui-stepper [steps]="steps" [value]="step()" (valueChange)="onStepperInput($event)" class="mb-6" /> @if (step() === 1) { <div class="space-y-4"> @if (!fileSelected()) { <button type="button" class="border-border hover:border-primary/50 hover:bg-accent/50 focus-visible:ring-ring flex w-full cursor-pointer flex-col items-center gap-2 rounded-lg border border-dashed px-6 py-10 transition-colors focus-visible:ring-2 focus-visible:outline-none" (click)="fileSelected.set(true)" > <lucide-icon [img]="Upload" class="text-muted-foreground size-6" aria-hidden="true" /> <span class="text-sm font-medium">Drop your CSV here or browse</span> <span class="text-muted-foreground text-xs">Up to 10 MB · comma or semicolon separated</span> </button> } @else { <div class="border-border flex flex-wrap items-center gap-3 rounded-lg border p-4"> <span class="bg-muted text-muted-foreground flex size-9 items-center justify-center rounded-md" aria-hidden="true" > <lucide-icon [img]="FileSpreadsheet" class="size-4" /> </span> <div class="min-w-0 flex-1"> <p class="truncate text-sm font-medium">employees.csv</p> <p class="text-muted-foreground text-xs">248 rows · 6 columns · 38 KB</p> </div> <span ui-badge variant="success">Parsed</span> </div> } </div> } @if (step() === 2) { <div class="space-y-4"> @if (missingRequired().length > 0) { <ui-alert class="border-warning/40 bg-warning/5"> <ui-alert-title>Required fields unmapped</ui-alert-title> <ui-alert-description>Map {{ missingRequired().join(' and ') }} before continuing.</ui-alert-description> </ui-alert> } <ul class="divide-y rounded-lg border"> @for (column of columns; track column) { <li class="flex flex-wrap items-center gap-3 p-3"> <code class="w-32 shrink-0 truncate font-mono text-xs">{{ column }}</code> <lucide-icon [img]="ChevronRight" class="text-muted-foreground size-4 shrink-0" aria-hidden="true" /> <ui-select [value]="mapping()[column]" (valueChange)="setMapping(column, $event)"> <button ui-select-trigger size="sm" class="flex-1"> <ui-select-value /> </button> <ui-select-content> @for (option of targetOptions; track option) { <ui-select-item [value]="option">{{ option }}</ui-select-item> } </ui-select-content> </ui-select> @if (mapping()[column] !== 'Skip') { <span ui-badge [variant]="confidenceFor(column) === 'high' ? 'success' : 'info'"> {{ confidenceFor(column) === 'high' ? 'Auto · high' : 'Auto · medium' }} </span> } </li> } </ul> </div> } @if (step() === 3) { <div class="space-y-3"> <div class="overflow-x-auto rounded-lg border"> <table class="w-full max-w-[640px] min-w-full text-sm"> <thead> <tr class="bg-muted/50 border-b"> @for (column of columns; track column) { <th class="px-3 py-2 text-left font-medium"> {{ mapping()[column] === 'Skip' ? '—' : mapping()[column] }} </th> } </tr> </thead> <tbody> @for (row of previewRows; track $index) { <tr class="border-b last:border-b-0"> @for (column of columns; track column) { <td [class]="cellClass(row, column)" [attr.title]="cellInvalid(row, column) ? 'This value will be rejected' : null" > {{ mapping()[column] === 'Skip' ? '·' : row[column] }} </td> } </tr> } </tbody> </table> </div> <p class="text-muted-foreground text-xs"> Showing first 5 of 248 rows · <span [class]="flaggedCount() > 0 ? 'text-warning font-medium' : 'text-success font-medium'"> {{ importedCount }} valid · {{ flaggedCount() }} flagged </span> </p> </div> } @if (step() === 4) { <div class="space-y-4 py-4 text-center"> <lucide-icon [img]="CheckCircle2" class="text-success mx-auto size-10" aria-hidden="true" /> <div> <p class="text-lg font-semibold">{{ importedCount }} records imported</p> <p class="text-muted-foreground mt-1 text-sm"> {{ flaggedCount() }} rows were skipped — fix them and re-import anytime. </p> </div> <button ui-button variant="outline" size="sm" (click)="reset()">Import another file</button> </div> } <div slot="footer" class="flex w-full items-center justify-between"> @if (step() > 1 && step() < 4) { <button ui-button variant="ghost" size="sm" class="px-2.5" (click)="step.set(step() - 1)"> <lucide-icon [img]="ChevronLeft" aria-hidden="true" /> Back </button> } @else { <span></span> } @if (step() < 4) { <button ui-button size="sm" class="px-2.5" [disabled]="step() === 2 && missingRequired().length > 0" (click)="goNext()" > {{ step() === 1 ? 'Continue' : step() === 2 ? 'Review' : 'Import' }} <lucide-icon [img]="ChevronRight" aria-hidden="true" /> </button> } </div> </ui-section-card> `, }) export class UiImportWizardComponent implements OnInit { protected readonly CheckCircle2 = CheckCircle2 protected readonly ChevronLeft = ChevronLeft protected readonly ChevronRight = ChevronRight protected readonly FileSpreadsheet = FileSpreadsheet protected readonly Upload = Upload protected readonly steps = steps protected readonly columns = columns protected readonly targetOptions = targetOptions protected readonly previewRows = rows.slice(0, 5) protected readonly importedCount = 245 /** Jump straight to a step (1 Upload · 2 Map · 3 Preview · 4 Result). */ @Input() initialStep = 1 /** Show the upload step with the file already parsed. */ @Input() initialFileSelected = false /** Override the auto-mapping so demos can show unmapped required fields. */ @Input() initialMapping?: Record<string, TargetField> @Input('class') className?: string readonly step = signal(1) readonly fileSelected = signal(false) readonly mapping = signal<Record<CsvColumn, TargetField>>({ ...defaultMapping }) constructor() {} ngOnInit(): void { this.step.set(this.initialStep) this.fileSelected.set(this.initialFileSelected || this.initialStep > 1) if (this.initialMapping) this.mapping.set({ ...defaultMapping, ...this.initialMapping }) } readonly mappedTargets = computed<TargetField[]>(() => (Object.values(this.mapping()) as TargetField[]).filter((t) => t !== 'Skip'), ) readonly missingRequired = computed(() => requiredTargets.filter((target) => !this.mappedTargets().includes(target))) readonly flaggedCount = computed(() => rows.filter((row) => columns.some((col) => this.cellInvalid(row, col))).length) confidenceFor(column: CsvColumn): 'high' | 'medium' { return confidence[column] } cellInvalid(row: CsvRow, column: string): boolean { if (this.mapping()[column as CsvColumn] === 'Full name') return row.full_name.trim() === '' if (this.mapping()[column as CsvColumn] === 'Email') return !EMAIL_RE.test(row.email_address) return false } cellClass(row: CsvRow, column: CsvColumn): string { return cn('px-3 py-2', this.cellInvalid(row, column) && 'bg-destructive/10 text-destructive font-medium') } setMapping(column: CsvColumn, value: string): void { this.mapping.update((prev) => ({ ...prev, [column]: value as TargetField })) } onStepperInput(value: number): void { // Free backward navigation; forward goes through the footer button only. if (value < this.step() && this.step() !== 4) this.step.set(value) } goNext(): void { if (this.step() === 1 && !this.fileSelected()) this.fileSelected.set(true) if (this.step() === 2 && this.missingRequired().length > 0) return if (this.step() < 4) this.step.set(this.step() + 1) } reset(): void { this.step.set(1) this.fileSelected.set(false) this.mapping.set({ ...defaultMapping }) } }src/app/components/blocks/import-wizard/index.ts0.1 kB
export { UiImportWizardComponent, type TargetField } from './import-wizard.component'
Raw manifest:https://uipkge.dev/r/angular/import-wizard.json