Component contract
Renders a CSV preview UI: column mapping, row validation results, commit / cancel. Stateless wrt the CSV itself (parent supplies parsed rows + headers).
headers: string[]— column headers from the uploaded file.rows: Record<string, string>[]— parsed rows.fields: FieldDef[]— id, label, required, validate?: (value) => ValidationResult, autoMatch?: string[] (header names that auto-bind).mapping: Record<fieldId, headerName | null>— controlled.onMappingChange: (next) => voidonCommit: (validRows) => Promise<CommitResult>— fires only on user click of Commit, only with the validated rows. Server returns per-row success/failure.onCancel: () => voididempotencyKey?: string— sent on commit to dedupe retries.maxPreviewRows?: number— defaults to 50; full file is processed on commit but preview is bounded.
Interaction surface
-
Header → field mapping.
Each field row shows: field label (left), dropdown of available headers (right). autoMatch hints pre-populate the dropdown when a header name matches (case-insensitive, with common variants like "First Name" → firstName).
-
Validation runs as mapping changes.
Each row is validated against the current mapping. Per-cell error markers in the preview table. Summary counts: "47 valid · 3 errors · 0 warnings".
-
Required field unmapped blocks commit.
If any required field has no mapping, Commit is disabled with a tooltip listing the missing fields.
-
Per-row error display.
Errored rows are visually marked (red row tint + icon) with hover/focus showing the per-cell error message. Errored rows are excluded from commit unless the user explicitly fixes them.
-
Commit flow.
Click Commit → confirmation modal: "Import 47 rows? 3 rows will be skipped due to errors." On confirm, onCommit fires with the valid rows. Component shows in-flight state.
-
Server-side per-row results displayed after commit.
CommitResult includes per-row outcomes (some may have failed server-side even though they passed client validation). The component renders a results panel: "44 imported · 3 failed · download error report" link.
Failure modes
Commit submits all rows including invalid ones
Trigger: client-side validation flags 3 errors but commit sends all 50 rows.
Commit sends only valid rows. Errored rows surface in the result panel as "skipped". Harness: 50 rows / 3 invalid, click Commit, assert onCommit called with array.length=47.
Required field "soft" mapped to a missing header
Trigger: required field maps to a header that doesn't exist in headers (e.g., the user previously had this mapping saved but the new file lacks the column); component treats it as mapped.
Mapping validates that the mapped header is present in headers. If not, render as unmapped + tooltip "header not found in file". Harness: mapping={firstName: "First Name"}, headers=["FirstName"], assert mapping shown as unmapped.
autoMatch makes wrong assumptions silently
Trigger: a column "ID" auto-matches to "registrationId" but it's actually "ticketId" — user doesn't notice.
Auto-matched mappings are visually marked as "auto" and the user is prompted to confirm before commit (or at least visibility-marked so they can spot mistakes). Harness: render with autoMatch fired, assert mapping rows have data-auto-matched=true visible indicator.
Commit retries duplicate the import server-side
Trigger: commit succeeds halfway, network drops, user retries; server now has 50 + 47 = 97 rows.
Commit includes Idempotency-Key header derived from idempotencyKey + row hash. Server dedupes. Harness: stub onCommit to fail mid-flight, retry, second commit's request carries same Idempotency-Key.
Per-row error popover not keyboard-accessible
Trigger: hovering an errored cell shows the message; keyboard focus shows nothing.
Each errored cell has aria-describedby pointing at a hidden error span; focus on the cell shows the same popover. Harness: focus an errored cell, popover visible / aria-describedby resolves.
Preview-only render when file is large
Trigger: 10,000-row file shows "preview of 50 rows" but commit silently uploads only the previewed 50.
Commit sends ALL rows the parent passed in, regardless of preview cap. Banner clearly states "Showing first 50 of 10,000 rows. All 10,000 will be imported." Harness: rows.length=10000, maxPreviewRows=50, click commit, assert onCommit called with 10000-row array.
Cancel mid-mapping loses no-edit-modal-needed state
Trigger: user maps fields, clicks Cancel, no confirmation. They lose 5 minutes of mapping work without prompt.
Cancel checks if mapping has been edited since render; if so, opens ui-destructive-confirmation. Harness: edit mapping, click Cancel, assert ui-destructive-confirmation visible.
Error report download missing context
Trigger: post-commit, "download error report" returns a CSV with just row indices; user can't tell which input row had which error.
Error report includes: original row data + the field that errored + the error message + the row's position in the source file. Harness: stub commit to return 3 errors, click download error report, downloaded CSV contains all 4 columns.
Accessibility
- Mapping section uses a description list (
<dl>) with field labels (<dt>) and dropdowns (<dd>). - Preview table inherits ui-data-table a11y where applicable.
- Errored cells have
aria-invalid="true"+aria-describedbypointing at error span. - Results panel after commit has
role="status"+aria-live="polite". - Color contrast: error markers ≥ 4.5:1, error tint distinguishable from default ≥ 3:1.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-csv-import-preview | Outer wrapper | Component identity |
ui-csv-import-mapping-row | Each field-to-header mapping row | data-field-id; data-auto-matched=true|false |
ui-csv-import-mapping-select | Header dropdown | Per row |
ui-csv-import-preview-table | Preview data table | Embedded ui-data-table |
ui-csv-import-preview-row | Each preview row | data-row-status=valid|error |
ui-csv-import-preview-cell-error | Errored cell marker | aria-invalid=true |
ui-csv-import-summary | Counts summary | "47 valid · 3 errors · 0 warnings" |
ui-csv-import-large-file-banner | Banner when rows > preview cap | Visible only when truncated preview |
ui-csv-import-commit | Commit button | Disabled until required fields mapped + at least 1 valid row |
ui-csv-import-cancel | Cancel button | Always visible |
ui-csv-import-results | Post-commit results panel | Visible after onCommit settles |
ui-csv-import-results-error-report | Download error report link | Visible when commit had per-row errors |
Agent test plan
Standalone probes against /admin-test/ui-csv-import-preview-fixture with variants: 5-rows, 10000-rows, all-valid, partial-invalid, missing-required-mapping, auto-matched, commit-success, commit-partial-failure, missing-header.
Probe list
- mapping-renders-each-field: ui-csv-import-mapping-row count === fields.length
- auto-match-applies-on-render: header "First Name", field firstName autoMatch=["first name"], mapping pre-populated AND data-auto-matched=true
- mapping-missing-header-falls-back: mapping references absent header → unmapped
- required-unmapped-disables-commit: required field unmapped, ui-csv-import-commit disabled
- summary-counts-correct: 50 rows / 3 invalid → text matches "47 valid · 3 errors"
- errored-row-marked: invalid row has data-row-status=error AND row tint
- errored-cell-aria-invalid: errored cell has aria-invalid=true + aria-describedby
- errored-cell-keyboard-popover: focus errored cell, popover visible
- commit-sends-only-valid: 50 rows / 3 invalid, click commit, onCommit args[0].length=47
- commit-includes-all-rows-when-large: rows.length=10000, click commit, onCommit args[0].length=10000 (not 50)
- large-file-banner-visible: rows.length > maxPreviewRows → ui-csv-import-large-file-banner visible
- commit-shows-confirmation: click commit, ui-destructive-confirmation visible (or similar prompt)
- commit-includes-idempotency-key: commit fetch includes Idempotency-Key header
- commit-retry-same-key: stub commit fail, retry, second fetch same Idempotency-Key
- post-commit-results-shown: onCommit returns {imported: 44, failed: 3} → ui-csv-import-results visible with counts
- post-commit-error-report-link: onCommit had errors → ui-csv-import-results-error-report visible
- error-report-download-content: click download, CSV has row data + field + message + position
- cancel-with-edits-prompts: edited mapping then cancel → ui-destructive-confirmation visible
- cancel-clean-immediate: no edits + cancel → onCancel called immediately
- color-contrast-error-marker: ≥ 4.5:1
- color-contrast-row-tint: ≥ 3:1
- axe-clean-serious: no serious violations