Repository navigation
FileUploadInput
FileUploadInput<T> is a generic, pluggable file upload component built on Carbon's FileUploaderDropContainer. It handles file selection, display, slot-limit enforcement, per-file size guards, and loading-spinner state while remaining entirely ignorant of file structure and business rules.
All domain logic — extension checking, header validation, row parsing, and transformation into typed domain objects — lives in a pluggable FileProcessor<T> that the caller provides. This separation makes the component reusable across any file format or domain model without modification.
FileUploadInput<T>
│
├── File selection (drag-and-drop or click)
├── Slot limit enforcement (maxFiles)
├── Per-file size guard (before processor is invoked)
├── Loading spinner per file while processor runs
├── Per-file error surfacing (from processor or size guard)
├── Component-level error surfacing (slot overflow, externalErrors)
└── FileProcessor<T> ← caller provides this
├── Extension validation
├── Header / schema validation
├── Row parsing and transformation
└── Returns ProcessorResult<T> (success | failure)
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
processor |
FileProcessor<T> |
Yes | — | Pluggable processor that owns all file-specific logic. |
onProcessed |
(results: T[]) => void |
Yes | — | Called with the flat list of all parsed domain objects across all accepted files whenever the set of successfully processed files changes. |
accept |
string |
No | ".csv,.xlsx" |
Accept attribute string passed to the native input, e.g. ".csv" or ".csv,.xlsx". |
maxFileSizeBytes |
number |
No |
512000 (500 KB) |
Maximum file size in bytes. Files exceeding this limit are rejected before the processor is invoked. |
maxFiles |
number |
No | 1 |
Maximum number of files the component will hold simultaneously. |
externalErrors |
string[] |
No | [] |
Externally-injected errors (e.g. from a Zod parse on form submit). Rendered at the component level, not on individual file rows. The parent is responsible for clearing these. |
disabled |
boolean |
No | false |
Disables the drop container and prevents new file selection. |
The FileProcessor<T> interface lives in src/components/Form/FileUploadInput/fileProcessor.ts. Implement it to provide all file-specific logic:
import { type FileProcessor, type ProcessorResult } from '@/components/Form/FileUploadInput/fileProcessor';
class MyProcessor implements FileProcessor<MyType> {
async load(file: File): Promise<ProcessorResult<MyType>> {
// 1. Extension check
// 2. Header / schema validation
// 3. Row parsing and per-row validation
// 4. Return { success: true, data: [...] } or { success: false, errors: [...] }
}
}// Success
{ success: true; data: T[] }
// Failure — errors are shown on the file row in the UI
{ success: false; errors: string[] }Design rule: The processor is the only place that inspects file contents. FileUploadInput never reads bytes directly.
The minimal integration: one file, .csv only, processor returns typed records.
import FileUploadInput from '@/components/Form/FileUploadInput';
import { CustomerCsvProcessor } from '@/components/Form/FileUploadInput/examples/CustomerCsvProcessor';
// Instantiate outside the component so the reference is stable across renders.
const customerCsvProcessor = new CustomerCsvProcessor();
function CustomerUploadSection() {
return (
<FileUploadInput
accept=".csv"
processor={customerCsvProcessor}
onProcessed={(customers) => {
// customers is Customer[] — fully typed, validated, ready to use
console.log('Parsed customers:', customers);
}}
/>
);
}CustomerCsvProcessor validates the extension, checks that the required id, name, and email columns are present, parses each row with Zod, and returns the typed Customer[] array. The component never sees the raw CSV.
When the processor returns { success: false, errors: [...] }, the component marks the file row as invalid and displays each error message under it. onProcessed is not called.
CustomerCsvProcessor reports three distinct failure layers — each surfaces as a separate message on the file row in the UI:
Layer 1 — Wrong extension:
Upload: customers.txt
Error : "customers.txt" must be a .csv file.
Layer 2 — Missing header columns:
Upload: customers.csv (headers: id,name — missing email)
Error : Missing required columns: email.
Layer 3 — Row-level Zod validation errors (all rows reported at once):
Upload: customers.csv
id,name,email
1,Alice,not-an-email <- invalid email
2,,bob@example.com <- empty name
Errors: Row 2: email must be a valid email address
Row 3: name is required
// The component surfaces each processor error on the file row.
// onProcessed is never called for failed files.
<FileUploadInput
accept=".csv"
processor={customerCsvProcessor}
onProcessed={setCustomers}
/>Design note: Collect all row errors before returning — never throw. Returning after the first bad row means the user has to fix and re-upload multiple times.
The size guard runs before the processor. If a file exceeds maxFileSizeBytes, the processor is never called and a size error appears on the file row.
<FileUploadInput
processor={customerCsvProcessor}
onProcessed={setCustomers}
maxFileSizeBytes={100 * 1024} // 100 KB limit
/>
// Uploading a 200 KB file shows:
// "customers.csv" exceeds the maximum file size of 100 KB.When all slots are filled, a component-level warning is rendered and the processor is not called for the rejected files.
// maxFiles={1} — uploading a second file shows:
// "Maximum of 1 file already selected."
<FileUploadInput
accept=".csv"
maxFiles={1}
processor={customerCsvProcessor}
onProcessed={setCustomers}
/>
// Batching: uploading 4 files when maxFiles={2} shows:
// "2 files were not added — maximum of 2 files allowed."
<FileUploadInput
accept=".csv"
maxFiles={2}
processor={customerCsvProcessor}
onProcessed={setCustomers}
/>onProcessed bridges the component's output into a form field. externalErrors feeds Zod/TanStack validation messages back from the form layer into the component.
import { useForm } from '@tanstack/react-form';
import FileUploadInput from '@/components/Form/FileUploadInput';
import { Customer, CustomerCsvProcessor } from '@/components/Form/FileUploadInput/examples/CustomerCsvProcessor';
interface CustomerImportFormValues {
customers: Customer[];
}
// Stable reference — defined outside the component.
const processor = new CustomerCsvProcessor();
function CustomerImportForm() {
const form = useForm<CustomerImportFormValues>({
defaultValues: { customers: [] },
onSubmit: async ({ value }) => {
await api.importCustomers(value.customers);
},
});
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.Field
name="customers"
validators={{
onSubmit: ({ value }) =>
value.length === 0 ? 'Upload at least one customer file.' : undefined,
}}
>
{(field) => (
<FileUploadInput<Customer>
accept=".csv"
processor={processor}
onProcessed={(customers) => field.handleChange(customers)}
externalErrors={
field.state.meta.errors.length > 0
? field.state.meta.errors.map(String)
: undefined
}
/>
)}
</form.Field>
<form.Subscribe selector={(s) => s.isSubmitting}>
{(isSubmitting) => (
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Importing…' : 'Import customers'}
</Button>
)}
</form.Subscribe>
</form>
);
}Key points:
-
onProcessedreplaces the field value — the form never receives rawFileobjects. -
externalErrorsfeeds submit-time validation messages back into the component's error area. - The processor instance is created outside the component to keep the reference stable.
- Import
FileProcessorandProcessorResultfromfileProcessor.ts. - Validate the file extension first — return early with a user-friendly error.
- Read file contents (
.text()for CSV/text,arrayBuffer()for binary). - Validate the header / schema structure before processing rows.
- Collect per-row errors rather than throwing — return all errors at once.
- Return
{ success: true, data }or{ success: false, errors }.
The following implementation is the reference processor used across all examples in this page. It demonstrates every pattern — extension guard, CRLF-safe line splitting, header normalisation, blank-line skipping, and Zod row validation.
import { z } from 'zod';
import { type FileProcessor, type ProcessorResult } from '@/components/Form/FileUploadInput/fileProcessor';
// ---------------------------------------------------------------------------
// Domain schema
// ---------------------------------------------------------------------------
const customerRowSchema = z.object({
id: z.string().min(1, 'id is required'),
name: z.string().min(1, 'name is required'),
email: z.email({ message: 'email must be a valid email address' }),
});
export type Customer = z.infer<typeof customerRowSchema>;
// ---------------------------------------------------------------------------
// Processor
// ---------------------------------------------------------------------------
export class CustomerCsvProcessor implements FileProcessor<Customer> {
async load(file: File): Promise<ProcessorResult<Customer>> {
// 1. Extension guard — fail fast before reading any bytes.
if (!file.name.toLowerCase().endsWith('.csv')) {
return { success: false, errors: [`"${file.name}" must be a .csv file.`] };
}
// 2. Read and split — /\r?\n/ handles both LF (macOS/Linux) and CRLF (Windows).
const lines = (await file.text()).trim().split(/\r?\n/);
if (lines.length < 2) {
return { success: false, errors: [`"${file.name}" contains no data rows.`] };
}
// 3. Header validation — normalise to lowercase and trim whitespace.
// Extra columns are silently ignored; only required ones are checked.
const [headerLine, ...dataLines] = lines;
const headers = headerLine.split(',').map(h => h.trim().toLowerCase());
const required = ['id', 'name', 'email'] as const;
const missing = required.filter(col => !headers.includes(col));
if (missing.length > 0) {
return { success: false, errors: [`Missing required columns: ${missing.join(', ')}.`] };
}
// 4. Row-by-row Zod validation — collect ALL errors before returning.
// Row index is 1-based from the user's perspective (header = row 1).
const customers: Customer[] = [];
const rowErrors: string[] = [];
for (let i = 0; i < dataLines.length; i++) {
const line = dataLines[i].trim();
if (!line) continue; // skip blank lines
const values = line.split(',').map(v => v.trim());
const raw = Object.fromEntries(headers.map((h, j) => [h, values[j] ?? '']));
const result = customerRowSchema.safeParse(raw);
if (result.success) {
customers.push(result.data);
} else {
rowErrors.push(`Row ${i + 2}: ${result.error.issues.map(e => e.message).join('; ')}`);
}
}
if (rowErrors.length > 0) return { success: false, errors: rowErrors };
if (customers.length === 0) return { success: false, errors: [`"${file.name}" contains no data rows.`] };
return { success: true, data: customers };
}
}What this implementation covers:
| Pattern | Where |
|---|---|
| Extension guard | Before file.text() — no wasted I/O |
| CRLF-safe split |
/\r?\n/ — files exported from Excel/Windows work correctly |
| Header normalisation |
.toLowerCase().trim() — column order and case do not matter |
| Extra columns ignored |
headers.map(...) only picks up required keys |
| Blank-line skipping |
if (!line) continue — trailing newlines and empty rows are harmless |
| Value trimming |
.trim() on each cell — no leading/trailing space errors |
| Collect-all errors | Loop does not short-circuit — user sees every broken row in one pass |
| 1-based row numbers |
i + 2 because header is row 1; matches what a user sees in Excel |
Processors are pure async functions and can be tested completely in isolation — no component rendering required. Use File to construct synthetic inputs.
import { describe, it, expect } from 'vitest';
import { CustomerCsvProcessor } from './CustomerCsvProcessor';
describe('CustomerCsvProcessor', () => {
// Happy path — single valid row
it('parses valid CSV', async () => {
const file = new File(
['id,name,email\n1,Alice,alice@example.com\n'],
'customers.csv',
{ type: 'text/csv' },
);
const result = await new CustomerCsvProcessor().load(file);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([{ id: '1', name: 'Alice', email: 'alice@example.com' }]);
}
});
// Edge: CRLF line endings (Excel / Windows export)
it('handles CRLF line endings', async () => {
const file = new File(
['id,name,email\r\n1,Alice,alice@example.com\r\n'],
'customers.csv',
{ type: 'text/csv' },
);
const result = await new CustomerCsvProcessor().load(file);
expect(result.success).toBe(true);
});
// Edge: extra columns are silently ignored
it('ignores extra columns', async () => {
const file = new File(
['id,name,email,age\n1,Alice,alice@example.com,30\n'],
'customers.csv',
{ type: 'text/csv' },
);
const result = await new CustomerCsvProcessor().load(file);
expect(result.success).toBe(true);
if (result.success) expect(result.data[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
});
// Failure: wrong extension
it('rejects non-.csv files', async () => {
const file = new File(['id,name,email\n1,Alice,alice@example.com\n'], 'customers.txt');
const result = await new CustomerCsvProcessor().load(file);
expect(result.success).toBe(false);
if (!result.success) expect(result.errors[0]).toMatch(/\.csv file/i);
});
// Failure: missing columns
it('reports missing required columns', async () => {
const file = new File(['id,name\n1,Alice\n'], 'customers.csv', { type: 'text/csv' });
const result = await new CustomerCsvProcessor().load(file);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors[0]).toMatch(/Missing required columns/i);
expect(result.errors[0]).toContain('email');
}
});
// Failure: row-level Zod errors — all rows reported in a single pass
it('collects all row errors before returning', async () => {
const csv = 'id,name,email\n1,Alice,not-an-email\n2,,bob@example.com\n';
const file = new File([csv], 'customers.csv', { type: 'text/csv' });
const result = await new CustomerCsvProcessor().load(file);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors).toHaveLength(2);
expect(result.errors[0]).toContain('Row 2');
expect(result.errors[1]).toContain('Row 3');
}
});
});Testing checklist for any processor:
- Happy path: valid file, all required columns, valid rows
- Wrong extension →
success: false - Empty file →
success: false - Header-only file (no data rows) →
success: false - Missing required columns →
success: falsewith column names in error - Row-level validation errors → all rows reported, not just the first
- Extra columns → silently ignored, result is
success: true - CRLF line endings → parsed correctly
- Blank/whitespace-only lines → skipped
- Processor stability: Instantiate the processor outside the React component. Creating it inside the render function produces a new reference on every render, which can cause unexpected behavior.
-
Result aggregation:
onProcessedis called with the flat union of all currently accepted, successfully processed files. Deleting a file triggers a new call reflecting the reduced set. -
No content-type check: The component relies on the native
acceptattribute and the processor for extension/type enforcement. The browser'sacceptfilter is advisory only — the processor must validate the extension itself. - Slot guard is eager: Overflow detection happens at the point of adding files. If the user deletes a file first, new slots open automatically.
-
External errors:
externalErrorsare merged with component-level errors in the alert area. The parent must clear them when the user corrects the input (e.g. on next form field change). -
Async concurrency: Multiple files in a batch are processed concurrently with
Promise.all.onProcessedis called once after all processors in the batch resolve.
-
codes/frontend/Components.md— the Form component table references this page; update the entry if the description changes. - Any page or component that implements
FileProcessor<T>depends on the interface infileProcessor.ts.
-
fileProcessor.ts—FileProcessor<T>andProcessorResult<T>contracts -
CustomerCsvProcessor.ts— reference processor implementation with Zod validation (extension guard · CRLF · header normalisation · row-level error collection) -
CustomerCsvProcessor.unit.test.ts— exhaustive test suite for the reference processor -
CustomerImportForm.tsx— complete TanStack Form integration example - Carbon FileUploaderDropContainer — underlying Carbon component
- Form Components — full list of Alliance form components
This wiki documents NR Waste Plus for users and developers. Verify security and deployment guidance against the current application configuration before applying it elsewhere.
- Report an issue or ask a question in the GitHub repository.
- Follow the Contributing Guidelines to propose an update.
- Read the Code of Conduct.
Collection indexes support human browsing and machine retrieval. Keep both current when adding or moving wiki pages.
NR Waste Plus Wiki | GitHub Repository