# CAIRO-4118 — Import CSV Modal: Error Handling, Validation & Edge Cases

**Ticket:** [CAIRO-4118](https://wix.atlassian.net/browse/CAIRO-4118) · Status: In Progress · Priority: High
**Area:** `packages/cairo/src/components/ImportButton`, `packages/core/src/state/ImportState`

This tracker turns the ticket's task spec into individually-trackable items. All rows start `open`; flip to `done` / `dismiss` as resolved and record what was actually done (commit SHA, test name, rationale).

## General

| # | Task | Desired behavior | Status | Resolution / Notes |
|---|---|---|---|---|
| G1 | Success toast | On successful import, show a success toast | dismiss | Superseded by [CAIRO-4442](https://wix.atlassian.net/browse/CAIRO-4442): completion is now reported by a summary modal (created/updated/failed counts) shown on **every** finished import, not a toast. |

## Step 1 — Upload: File-Level Failures

| # | Failure | Desired behavior | Status | Resolution / Notes |
|---|---|---|---|---|
| 1.1 | File can't be read | Toast: *"Couldn't read this file. Check your internet connection and that the file hasn't been moved or deleted."* + **"Try Again"** action | done | `ImportState.onFileSelected` now shows an ERROR toast (key `cairo.import.fileReadFailed`) with a **Try Again** action (`cairo.import.tryAgain`) that re-runs `onFileSelected(file)` on the `file.text()` read failure, instead of the inline parse-error text. Tests: *"shows a try-again toast and stays on upload step when the file cannot be read"*, *"retries reading the file when the toast try-again action is clicked"* in `Table.importButton.spec.tsx`. New key must be registered in the Translation Center (core `messages_en.json` is gitignored). |
| 1.2 | Malformed CSV (parse error) | Toast: *"Couldn't process this file. Make sure it's a comma-separated CSV file."* | done | `ImportState.onFileSelected` now fires an ERROR toast (key `cairo.import.processFailed`) on a Papa parse error and stays on the upload step, instead of the inline `parseError` text. Removed the now-dead `parseError` observable, the inline `import-upload-error` UI in `ImportStepUpload.tsx`, and the `getUploadError`/`uploadErrorExists` driver methods. Test: *"shows an error toast and stays on upload step for a malformed CSV"* in `Table.importButton.spec.tsx`. New key must be registered in the Translation Center (core `messages_en.json` is gitignored). |
| 1.3 | Non-UTF-8 encoding | Toast: *"Couldn't process this file due to its encoding. Make sure it's saved with UTF-8 encoding."* | done | `onFileSelected` reads raw bytes (`file.arrayBuffer()`) and decodes via `new TextDecoder('utf-8', { fatal: true })`, which throws on invalid UTF-8 — validating encoding against the bytes *before* decoding (the earlier U+FFFD-string heuristic was lossy / produced false positives; see PR #5468 review). A decode failure → ERROR toast (key `cairo.import.encodingFailed`) via `_rejectFile`, staying on upload step. `parseCsv` is now string-only. Test env polyfills `TextEncoder`/`TextDecoder` in `__tests__/spec-setup.ts`. Test: *"shows an encoding error toast and stays on upload step for a non-UTF-8 file"*. New key must be registered in the Translation Center (core `messages_en.json` is gitignored). |
| ~~1.4~~ | ~~File too large / too many rows~~ | ~~Deferred until API size limit is known~~ | dismiss | Deferred in ticket (struck through) |

## Step 2 — Configuration: Validations

| # | Case | Desired behavior | Status | Resolution / Notes |
|---|---|---|---|---|
| 2.1 | Duplicate field mapping | Once a collection field is selected in one dropdown, **disable** it (visible but unselectable) in all other dropdowns | done | Shipped in PR #5130 (`feat(Import): disable already selected fields`). `ImportState.selectedFieldIds` (computed `Set` of mapped field ids) drives the per-dropdown `disabled` flag in `ImportStepConfiguration.tsx`: an option is disabled when `selectedFieldIds.has(option.id)` **and** it isn't the current header's own selection (so the field stays selectable in its own dropdown). Tests: *"disables already-selected fields in other dropdowns"*, *"does not disable a field in its own dropdown"* in `Table.importButton.spec.tsx` (both passing). |
| 2.2 | Zero data rows | Show an **inline warning notice** inside step 2 (non-blocking) — user can still proceed or go back. [Design](https://www.figma.com/design/RpB9iQ6a43xcvKiovmJaA1/Import?node-id=72-39072) | done | Inline `SectionHelper` (skin `warning`, hook `import-no-rows-warning`) in `ImportStepConfiguration.tsx` with keys `cairo.import.noRowsWarningTitle` / `noRowsWarningDescription`. Trigger broadened from `csvRows.length === 0` to the new `ImportState.hasNoMappedData` computed — true when there are mappings but **no mapped column holds data in any row** (matching the design copy "no data was found inside the mapped columns"); it reacts to mapping changes and stays false when nothing is mapped yet (that case is covered by the disabled Import button + `noMappingsTooltip`). Non-blocking: Import stays enabled whenever `hasMappings` is true. Tests in `Table.importButton.spec.tsx`: *"...shows warning for CSV with headers but no rows"*, *"shows the warning when rows exist but the mapped columns are empty"*, *"does not show the warning when a mapped column has data"*. Keys already registered in the Translation Center. |
| 2.3 | Mismatch between CSV data and chosen field | Dropdown enters **error state** with a status message | open | Tracked in [CAIRO-4392](https://wix.atlassian.net/browse/CAIRO-4392) (relates to CAIRO-4118). Needs design: reuse existing field validators to validate every CSV row client-side, with open questions on coverage/perf, blocking vs non-blocking, and how it reconciles with the planned client→server async-import migration. |

## Step 3 — Progress: Full Import Failures

| # | Failure | Desired behavior | Status | Resolution / Notes |
|---|---|---|---|---|
| 3.1 | Backup fails (pre-upload backup throws) | Put modal into **error state**; replace progress label with error message; footer CTA → **"Try again"** (re-runs import). [Design](https://www.figma.com/design/RpB9iQ6a43xcvKiovmJaA1/Import?node-id=145-12691) | done | When `_runBackup` rejects, `ImportState.importStatus` goes to `queryStatus.error` (existing). The progress step now renders the error state: `ImportStepProgress.tsx` replaces the `import-progress-label` with an `import-error-message` Text and sets `error` on the `LinearProgressBar` whenever `importStatus.isError` (message = `errorHandler.getResolvedError(...)?.message ?? cairo.import.genericError`). The progress-step footer in `getImportModalStepProps.tsx` swaps the disabled **Importing** primary button for an enabled **Try again** (`cairo.import.tryAgain`, re-runs `state.startImport`) and changes the title to `cairo.import.failedTitle` when `importStatus.isError`. `startImport` now resets `importStatus` → `idle` and `result` → zero at the start of each attempt so a retry begins from a clean slate. Tests in `Table.importButton.spec.tsx`: *"puts the modal into error state and replaces the progress label with an error message when backup fails"*, *"re-runs the import when the 'Try again' CTA is clicked after a backup failure"*. All keys (`tryAgain`, `genericError`, `failedTitle`) already registered in the Translation Center. |
| 3.2 | Full batch failure (network/server error on first batch) | Modal in **error state** with message and **try again** action | done | `ImportState._uploadBatches` now distinguishes a *thrown* batch error before anything was imported (`result.succeeded === 0`) from a partial failure: in that case it re-throws so `startImport`'s catch sets `importStatus` → `queryStatus.error`. This reuses the same error-state UI shipped for 3.1 (error message replacing the progress label, error `LinearProgressBar`, `cairo.import.failedTitle`, enabled **Try again** primary button re-running `startImport`). A thrown error *after* some batches succeeded keeps the existing partial-success inline message (that path becomes the 3.3 toast later). Tests in `Table.importButton.spec.tsx`: *"puts the modal into error state with a 'Try again' CTA when the first batch fails"* and *"re-runs the import when 'Try again' is clicked after a full batch failure"*. |
| 3.3 | Partial success — some rows don't make it in, whether a batch *returns* row `errors` or *throws* after some succeeded | Close modal; show toast with how many failed / succeeded + action to **download the CSV** | dismiss | Toast behavior superseded by [CAIRO-4442](https://wix.atlassian.net/browse/CAIRO-4442): the modal stays open and shows a summary step (created/updated/failed) with a **Download CSV** button for the failed rows; the CSV gains a leading ".Why it failed to import" reason column. The failed-row tracking described below still applies — only the toast + auto-close is replaced. Original implementation notes retained for reference: `ImportState._uploadBatches` accumulates a single `_failedRowIndices` (global indices into `csvRows`) across the whole run: (a) rows the server rejects inside an OK response are read from each batch's returned `errors[].rowIndex`, offset by the batch's start index; (b) when a batch *throws* after at least one OK response, every row from the throwing batch onward (the throwing batch + all un-attempted batches) is added on top of any already-rejected rows. If *no* OK response arrived before a throw, it re-throws → full batch failure (3.2). `startImport`'s success branch closes the modal, refreshes the collection, and — whenever `_failedRowIndices` is non-empty — fires a `WARNING` toast: message `cairo.import.partialSuccessToast` ("{failed} rows failed, {imported} rows imported", `failed = _failedRowIndices.length`, `imported = totalCount - failed`) with a `cairo.import.downloadFailedRows` ("Download failed rows") action. The action serializes `csvHeaders` + the failed rows via the `unparseCsv` helper (`parseCsv.ts`, `Papa.unparse`) and downloads through the `downloadCsv` prop, wired in `useImport.ts` from `container.window.download` as a `data:text/csv` URI (filename `failed-<original>`). `BulkUploadResults` was **not** changed — failed-row tracking lives entirely in `ImportState` (it owns `csvRows` + batching). The previous behavior, where a returned-`errors` batch left the modal open on an inline partial-success message, was the bug fixed here. Tests in `Table.importButton.spec.tsx`: throw path — *"closes the modal and shows a toast when a batch fails after some succeed"*, *"downloads only the failed rows when the toast download action is clicked"*; returned-errors path — *"closes the modal and shows a toast when a batch returns row errors"*, *"downloads the rejected rows when a batch returns row errors"*. New keys `partialSuccessToast` / `downloadFailedRows` must be registered in the Translation Center (core `messages_en.json` + generated `translations.ts` are gitignored). |

## Status summary

| # | Topic | Status |
|---|---|---|
| G1 | Success toast | dismiss (superseded by CAIRO-4442) |
| 1.1 | File can't be read | done |
| 1.2 | Malformed CSV | done |
| 1.3 | Non-UTF-8 encoding | done |
| 1.4 | File too large | dismiss (deferred) |
| 2.1 | Duplicate field mapping | done |
| 2.2 | Zero data rows | done |
| 2.3 | Field/data mismatch | open |
| 3.1 | Backup fails | done |
| 3.2 | Full batch failure | done |
| 3.3 | Partial success | dismiss (toast superseded by CAIRO-4442) |
