@nuport/file-import
v1.1.2
Published
React library for CSV/XLSX/XLS imports: parsing, column mapping, type casting, configurable validation, review-and-edit UI and downloadable error reports.
Readme
@nuport/file-import
React library for importing CSV, XLSX and XLS files: parsing, column matching, type casting, configurable validation, a spreadsheet-style review grid with bulk fixes, and downloadable error reports. Built as a replacement for Flatfile-style import flows, with no third-party service.
- Headless core (
@nuport/file-import/core): pure functions, no React, usable on the server. - React UI (
@nuport/file-import): theFileImportercomponent (modal or inline), theuseFileImporthook, and the building blocks for custom layouts. - Brings its own styling. One stylesheet driven by
--nfi-*CSS variables. Interactive parts are headless Radix primitives, so there is no Tailwind or component-library CSS to reconcile with your app. Works with React 18 and 19, Next.js (Pages and App Router), Vite, Tailwind, antd. - Flatfile blueprint compatible. Existing
fieldsarrays (key,label,type,constraints: [{ type: 'required' }],config.options) work unchanged.
Install
npm install @nuport/file-importPeer dependencies: react and react-dom ^18 || ^19. Radix packages (@radix-ui/react-dialog,
react-popover, react-select, react-toggle-group) are regular dependencies and install with it.
Quick start
import { FileImporter } from '@nuport/file-import'
import '@nuport/file-import/styles.css'
const config = {
name: 'Customers',
fields: [
{ key: 'name', label: 'Name', required: true },
{ key: 'phone', label: 'Phone', required: true, unique: true },
{ key: 'email', label: 'Email', alternativeNames: ['E-mail address'] },
{ key: 'age', label: 'Age', type: 'number', constraints: [{ type: 'min', value: 18 }] },
{ key: 'tier', label: 'Tier', type: 'enum', options: [{ value: 'gold', label: 'Gold' }] },
],
}
export function ImportButton() {
return (
<FileImporter
config={config}
trigger={(open) => <button onClick={open}>Import customers</button>}
onSubmit={async ({ validRows }) => {
await api.importCustomers(validRows.map((row) => row.values))
}}
/>
)
}onSubmit receives an ImportResult. If it throws, the error is shown and the user stays on the
review step.
What the user sees
The importer is three steps. Steps that need no input are skipped.
- Upload file. Drag-and-drop or browse. CSV, TSV, XLSX and XLS; multi-sheet workbooks get a sheet picker.
- Match columns. Shown only when something needs attention (configurable, see
mappingStep). It leads with the problems: a column picker for every required field that has no column, and a field picker for every file column that matched nothing. Auto-matched columns are listed underneath with a sample value each. Columns matched by fuzzy name similarity carry a "best guess" badge so the user can confirm them. All pickers are searchable. - Review & fix. An issues panel groups every problem by field and value (
Warehouse Namefrom_locationis not one of the options, 500 rows), with a one-shot fix next to each group: pick an option, type a replacement, or fill empty cells. One fix applies to every row sharing that value. A "remove rows with issues" button clears the rest. Below it, an All / Ready / Needs-fix filter and a spreadsheet-style grid with every configured field as a column (fields the file did not have start empty), click-to-edit cells, always-visible searchable dropdowns for enum and boolean fields, red cells for errors (message on hover), and per-row removal. Edits re-validate live, including duplicates. The footer offers the error report download, an optional server check, and the import button with the row count.
Back goes to the previous step with the file kept; "Change file" starts over.
How it works
file ─▶ parseFile ─▶ autoMapColumns ─▶ buildRows ─▶ cast ─▶ transform ─▶ validate ─▶ unique checks ─▶ review ─▶ onSubmit
(csv/xlsx) (exact + fuzzy) (type) (hooks) (constraints, hooks)- Parse. Format is detected from the file's leading bytes (zip → xlsx, OLE → xls) and then the extension. CSV/TSV is streamed through papaparse with delimiter auto-detection and BOM handling; workbooks are read with SheetJS (numbers raw, dates as ISO strings, first or chosen sheet).
- Match. Headers match fields by
key,labeloralternativeNames, ignoring case, spacing and punctuation. Columns still unmatched get a fuzzy pass: shared words with synonyms (Invoice ID→Invoice No.,Mobile→Phone,Qty→Quantity), a near-spelling (Warehose Name), every word of the field name present once filler words and compounds are accounted for (Quantity in a MasterCase/CBC→Master Case Quantity), or the same head word with only abbreviations around it (SO/STO Number→Order Number, whileCustomer Numberstays unmatched). Fuzzy matches are flaggedmatchedBy: 'fuzzy'and listed inguessedColumns. Each field is assigned to at most one column. - Cast. Cells are coerced to the field type: trimmed strings, numbers with thousands
separators removed, booleans (
yes/no/true/false/1/0), dates to ISO (YYYY-MM-DD,DD/MM/YYYY, ISO), enums by value or label (case-insensitive) to the canonical value; an enum whose options are still empty (for example loaded from an API later) accepts any value. Empty cells becomeundefined. - Validate. Constraints (
required,unique,pattern,min,max,minLength,maxLength), enum membership, field and row hooks, compositeduplicateKeys. Large files are validated in 1000-row slices so the UI stays responsive. - Review.
groupIssuesbuilds the issues panel;replaceCellTextapplies a bulk fix and re-validates the affected rows plus table-level uniqueness. Cell edits go throughprocessRow. - Submit.
validOnly(default) submits valid rows and reports how many were skipped;requireAllValidblocks until every row is clean.
Configuration
interface ImportConfig {
name: string // shown in the title; also used for the slug
slug?: string // used in the error report file name
fields: FieldConfig[]
allowUnmappedColumns?: boolean // keep unknown columns under their header (default false)
acceptedFormats?: ('csv' | 'xlsx' | 'xls')[] // default all three
maxFileSize?: number // bytes, default 50 MB
maxRows?: number
duplicateKeys?: Array<string | string[]> // composite uniqueness
transformRow?: (values, ctx) => values | void
validateRow?: (values, ctx) => string | CellError | CellError[] | null
validateOnServer?: (rows: ImportRow[]) => Promise<ServerRowError[]>
submitMode?: 'validOnly' | 'requireAllValid'
messages?: Partial<Record<ErrorCode, string | ((ctx) => string)>> // '{label}', '{key}', '{value}'
}
interface FieldConfig {
key: string
label?: string
type?: 'string' | 'number' | 'boolean' | 'date' | 'enum' // default 'string'
description?: string // shown as the column header tooltip
alternativeNames?: string[]
required?: boolean
unique?: boolean
options?: { value: string; label?: string }[] // enum
constraints?: Constraint[] // { type, value?, regex?, flags?, message? }
config?: { options?: EnumOption[] } // Flatfile-compatible alias
transform?: (value, ctx) => unknown
validate?: (value, ctx) => string | CellError | CellError[] | null
}Hooks run in this order per row: cast → field.transform → transformRow → constraints →
field.validate → validateRow. Uniqueness is computed over the whole table afterwards. A cell with
a cast error does not also get constraint errors.
Rows and results
interface ImportRow {
index: number // 1-based data row; the number shown in the grid, error report and duplicate messages
sourceRow: number // spreadsheet row (header is 1)
raw: Record<string, string> // text as uploaded (or edited), keyed by field key
values: Record<string, unknown> // cast + transformed
errors: CellError[] // { field: string | null, code, message, meta? }
valid: boolean
edited: boolean
editedFields: string[] // keys the user changed in the review step
}
interface ColumnMappingEntry {
column: number // zero-based column in the file
header: string
fieldKey: string | null
matchedBy?: 'exact' | 'fuzzy' // absent once the user picks a field by hand
}
interface MappingResult {
mapping: ColumnMappingEntry[]
unmappedColumns: number[]
guessedColumns: number[] // fuzzy matches the user should confirm
missingRequired: string[]
unmatchedFields: string[]
}
interface IssueGroup {
field: string | null // null for row-level errors
code: ErrorCode
message: string
text: string | null // the raw cell text every row in the group shares ('' for empty)
rowIndexes: number[]
}
interface ImportResult {
fileName: string
sheetName?: string
mapping: ColumnMappingEntry[]
rows: ImportRow[]
validRows: ImportRow[]
invalidRows: ImportRow[]
summary: { total; valid; invalid; duplicates; errorsByField }
}FileImporter props
| Prop | Type | Notes |
| ------------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| config | ImportConfig | Required. |
| onSubmit | (result) => void \| Promise<void> | Required. Throwing keeps the user on the review step. |
| onError | (error) => void | Parse, mapping, server and submit errors. |
| onCancel | () => void | |
| mode | 'modal' \| 'inline' | Default modal. |
| trigger | (open) => ReactNode | Uncontrolled modal launcher. |
| open, onClose | | Controlled modal visibility. |
| title | ReactNode | Default Import {name}. |
| labels | Partial<Labels> | Override any UI string (see DEFAULT_LABELS). |
| theme | 'light' \| 'dark' \| 'auto' | Default light; auto follows prefers-color-scheme. |
| mappingStep | 'auto' \| 'always' \| 'never' | auto (default) shows the step when a required field is unmatched, a match was a guess, or columns would be skipped. never fails with MISSING_REQUIRED_COLUMNS instead of asking. |
| pageSize | number | Review grid page size, default 50. |
| closeOnSubmit | boolean | Default true. |
| showFieldPreview | boolean | Default true: the upload step draws the expected columns as an empty spreadsheet behind the drop target. |
| previewRows | number | Empty rows drawn in that preview, default 8. |
| className | string | Added to the modal or inline root. |
All user-facing text lives in DEFAULT_LABELS and can be overridden per string through labels,
including the step names, the issues-panel wording and the search placeholder.
Theming
Import the stylesheet once and override tokens on :root (or on .nfi-root for a scoped theme).
The library declares its defaults with zero specificity, so your :root rule wins regardless of
import order:
:root {
--nfi-primary: #278ea5;
--nfi-radius: 0;
--nfi-font: 'Inter', sans-serif;
}Available tokens: --nfi-primary, --nfi-primary-contrast, --nfi-danger, --nfi-danger-bg,
--nfi-warning, --nfi-warning-bg, --nfi-success, --nfi-success-bg, --nfi-bg,
--nfi-bg-muted, --nfi-bg-hover, --nfi-text, --nfi-text-muted, --nfi-border,
--nfi-border-strong, --nfi-radius, --nfi-font, --nfi-font-mono, --nfi-font-size,
--nfi-z-index, --nfi-mask, --nfi-shadow, --nfi-shadow-sm. All classes are prefixed nfi-.
The modal, dropdown menus and the filter toggle are Radix primitives styled by the same stylesheet.
Menus render in a portal and carry the importer's data-nfi-theme, so dark mode applies to them too.
The modal is portaled to document.body and inherits its font unless --nfi-font is set.
Headless usage
import { runImportPipeline, buildErrorReport, rowsToCsvFile } from '@nuport/file-import/core'
const result = await runImportPipeline(file, config, { onProgress: console.log })
const csvForBackend = rowsToCsvFile(
result.validRows.map((r) => r.values),
{ headers: 'key' },
)
const report = buildErrorReport(result.rows, defineImportConfig(config).fields, {
headers: 'label',
})Lower-level pieces are exported too: parseFile, detectFormat, autoMapColumns, matchColumn,
headerTokens, assignColumn, buildRows, castValue, processRow, applyUniqueChecks,
validateRows, applyServerErrors, groupIssues, replaceCellText, summarize, toCsv,
recordsToCsv, downloadBlob, downloadCsv.
Your own UI
useFileImport exposes the state machine the component uses: status, parsed, mappingResult,
rows, visibleRows, summary, issues (grouped problems), filter (all / valid /
invalid), plus selectFile, selectSheet, setMapping, confirmMapping, updateCell,
replaceValue, removeRow, removeInvalidRows, setFilter, runServerValidation,
downloadErrorReport, submit, goBack, reset.
The building blocks are exported for custom layouts: Dropzone, MappingStep, ReviewStep,
IssuesPanel, Modal, Stepper, SegmentedControl, Select (Radix select for short lists) and
Combobox (searchable dropdown used for cells and pickers). Wrap them in ThemeContext.Provider to
pass the theme to portaled menus.
Error codes
REQUIRED, INVALID_NUMBER, INVALID_BOOLEAN, INVALID_DATE, INVALID_ENUM, DUPLICATE,
PATTERN, MIN, MAX, MIN_LENGTH, MAX_LENGTH, CUSTOM, SERVER on cells; ImportError with
FILE_TOO_LARGE, UNSUPPORTED_FORMAT, EMPTY_FILE, PARSE_ERROR, TOO_MANY_ROWS,
MISSING_REQUIRED_COLUMNS, INVALID_CONFIG for whole-file problems.
Integration guides
Development
npm install
npm test # vitest (jsdom; Radix needs the polyfills in tests/setup.ts)
npm run lint # eslint + prettier
npm run typecheck
npm run build # tsup → dist/ (esm, cjs, d.ts, styles.css)CI runs lint, typecheck, tests and build on every push and pull request.
Releases: bump version in package.json, update CHANGELOG.md, tag vX.Y.Z and push the tag.
The release workflow checks the tag against the package version and publishes to npm. It needs an
NPM_TOKEN repository secret from an npm account that owns the @nuport scope. Provenance
attestation is off because npm only supports it for public source repositories.
License
MIT © Nuport
