Menu

Change language

GitHub repo

Import Wizard

blockdevops

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.

Installation

$npx uipkge-ng@latest add import-wizard
Manifest:https://uipkge.dev/r/angular/import-wizard.jsonInstalls to:src/app/components/blocks/import-wizard/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CsvRow
interface CsvRow {
  full_name: string
  email_address: string
  dept: string
  job_title: string
  start_date: string
  manager: string
}

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: 'amara@acme.com',
        dept: 'Engineering',
        job_title: 'Backend engineer',
        start_date: '2026-09-01',
        manager: 'j.weber@acme.com',
      },
      {
        full_name: 'Marcus Lee',
        email_address: 'marcus.lee@acme.com',
        dept: 'Design',
        job_title: 'Product designer',
        start_date: '2026-09-01',
        manager: 'a.osei@acme.com',
      },
      {
        full_name: '',
        email_address: 'no-name@acme.com',
        dept: 'Sales',
        job_title: 'Account exec',
        start_date: '2026-09-08',
        manager: 'p.nair@acme.com',
      },
      {
        full_name: 'Priya Nair',
        email_address: 'not-an-email',
        dept: 'People',
        job_title: 'Recruiter',
        start_date: '2026-09-15',
        manager: 'a.osei@acme.com',
      },
      {
        full_name: 'Jonas Weber',
        email_address: 'jonas@acme.com',
        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

Raw manifest:https://uipkge.dev/r/angular/import-wizard.json