Skip to content

FileUploadInput

Paulo Gomes da Cruz Junior edited this page Sep 11, 2026 · 4 revisions

FileUploadInput Component

Overview

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)

Props

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.

FileProcessor Contract

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: [...] }
  }
}

ProcessorResult

// 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.


Usage Examples

Example 1 — Basic CSV upload (happy path)

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.


Example 2 — Processor validation failure (edge case)

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.


Example 3 — File size exceeded (edge case)

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.

Example 4 — Slot overflow (edge case)

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}
/>

Example 5 — TanStack Form integration (advanced)

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:

  • onProcessed replaces the field value — the form never receives raw File objects.
  • externalErrors feeds submit-time validation messages back into the component's error area.
  • The processor instance is created outside the component to keep the reference stable.

Writing a Custom FileProcessor

Step-by-step checklist

  1. Import FileProcessor and ProcessorResult from fileProcessor.ts.
  2. Validate the file extension first — return early with a user-friendly error.
  3. Read file contents (.text() for CSV/text, arrayBuffer() for binary).
  4. Validate the header / schema structure before processing rows.
  5. Collect per-row errors rather than throwing — return all errors at once.
  6. 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

Testing Your Processor

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: false with 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

Known Constraints

  • 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: onProcessed is 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 accept attribute and the processor for extension/type enforcement. The browser's accept filter 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: externalErrors are 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. onProcessed is called once after all processors in the batch resolve.

Change Impact

  • 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 in fileProcessor.ts.

See Also

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally