@expresscsv/react 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@expresscsv/react.svg)](https://www.npmjs.com/package/@expresscsv/react)
4
4
  ![license](https://img.shields.io/npm/l/@expresscsv/react.svg)
5
5
 
6
- React hook for embedding the [ExpressCSV](https://expresscsv.com) CSV import widget. Wraps [`@expresscsv/sdk`](https://www.npmjs.com/package/@expresscsv/sdk) with automatic lifecycle management and reactive state.
6
+ React hook for embedding the [ExpressCSV](https://expresscsv.com) CSV importer. Wraps [`@expresscsv/sdk`](https://www.npmjs.com/package/@expresscsv/sdk) with automatic lifecycle management and reactive state.
7
7
 
8
8
  If you want to define schemas in shared or backend code without any frontend dependencies, use [`@expresscsv/schemas`](https://www.npmjs.com/package/@expresscsv/schemas) for the schema definition itself, then pass that schema into `@expresscsv/react`.
9
9
 
@@ -36,7 +36,7 @@ const schema = x.row({
36
36
  function App() {
37
37
  const { open, isOpen } = useExpressCSV({
38
38
  schema,
39
- publishableKey: "your-publishable-key",
39
+ getSessionToken: async () => fetchSessionToken(),
40
40
  importIdentifier: "user-import",
41
41
  title: "Import Users",
42
42
  });
@@ -48,17 +48,19 @@ function App() {
48
48
  console.log(
49
49
  `Chunk ${chunk.currentChunkIndex + 1}/${chunk.totalChunks}`
50
50
  );
51
+ console.log("Session:", chunk.sessionId);
52
+ console.log("Idempotency key:", chunk.chunkIdempotencyKey);
51
53
  console.log("Records:", chunk.records);
52
54
  next();
53
55
  },
54
- onComplete: () => {
55
- console.log("All chunks processed");
56
+ onComplete: ({ sessionId }) => {
57
+ console.log("All chunks processed for", sessionId);
56
58
  },
57
- onCancel: () => {
58
- console.log("User cancelled");
59
+ onCancel: ({ sessionId }) => {
60
+ console.log("User cancelled", sessionId);
59
61
  },
60
- onError: (error) => {
61
- console.error("Import error:", error);
62
+ onError: (error, { sessionId }) => {
63
+ console.error("Import error for", sessionId, error);
62
64
  },
63
65
  });
64
66
  };
@@ -71,20 +73,20 @@ function App() {
71
73
  }
72
74
  ```
73
75
 
74
- Your `publishableKey` is available from the [ExpressCSV dashboard](https://expresscsv.com). Two key types are available: **production** keys for live usage, and **dev/testing** keys that provide unlimited test imports.
76
+ Have your backend keep the ExpressCSV secret key, call the ExpressCSV session-creation endpoint, and return a short-lived importer session token to the browser via `getSessionToken`.
75
77
 
76
78
  ## Preloading
77
79
 
78
- By default the widget preloads in a hidden iframe so it appears instantly when `open()` is called:
80
+ By default the importer preloads in a hidden iframe so it appears instantly when `open()` is called:
79
81
 
80
82
  ```tsx
81
83
  const { open } = useExpressCSV({
82
84
  schema,
83
- publishableKey: "your-publishable-key",
85
+ getSessionToken: async () => fetchSessionToken(),
84
86
  importIdentifier: "user-import",
85
87
  });
86
88
 
87
- // Widget displays instantly
89
+ // Importer displays instantly
88
90
  const handleImport = () => {
89
91
  open({
90
92
  onData: (chunk, next) => {
@@ -100,7 +102,7 @@ To disable preloading (there will be a brief loading screen instead):
100
102
  ```tsx
101
103
  const { open } = useExpressCSV({
102
104
  schema,
103
- publishableKey: "your-publishable-key",
105
+ getSessionToken: async () => fetchSessionToken(),
104
106
  importIdentifier: "user-import",
105
107
  preload: false,
106
108
  });
@@ -124,7 +126,7 @@ const candidateSchema = x.row({
124
126
 
125
127
  const { open } = useExpressCSV({
126
128
  schema: candidateSchema,
127
- publishableKey: "your-publishable-key",
129
+ getSessionToken: async () => fetchSessionToken(),
128
130
  importIdentifier: "candidate-import",
129
131
  templateDownload: {
130
132
  source: "generate",
@@ -142,7 +144,7 @@ const { open } = useExpressCSV({
142
144
 
143
145
  ## Theming and Styling
144
146
 
145
- Customize the widget's appearance with the `theme`, `colorMode`, `customCSS`, and `fonts` options.
147
+ Customize the importer's appearance with the `theme`, `colorMode`, `customCSS`, and `fonts` options.
146
148
 
147
149
  ### Theme
148
150
 
@@ -181,7 +183,7 @@ const dualTheme: ECSVTheme = {
181
183
  function App() {
182
184
  const { open } = useExpressCSV({
183
185
  schema,
184
- publishableKey: "your-publishable-key",
186
+ getSessionToken: async () => fetchSessionToken(),
185
187
  importIdentifier: "user-import",
186
188
  theme,
187
189
  });
@@ -227,7 +229,7 @@ Control light/dark mode with `colorMode`:
227
229
  ```tsx
228
230
  const { open } = useExpressCSV({
229
231
  schema,
230
- publishableKey: "your-publishable-key",
232
+ getSessionToken: async () => fetchSessionToken(),
231
233
  importIdentifier: "user-import",
232
234
  colorMode: "system", // 'light' | 'dark' | 'system'
233
235
  });
@@ -240,7 +242,7 @@ Inject custom CSS for fine-grained styling overrides.
240
242
  ```tsx
241
243
  const { open } = useExpressCSV({
242
244
  schema,
243
- publishableKey: "your-publishable-key",
245
+ getSessionToken: async () => fetchSessionToken(),
244
246
  importIdentifier: "user-import",
245
247
  customCSS: `
246
248
  .ecsv [data-step="upload"] {
@@ -260,7 +262,7 @@ Load custom fonts via the `fonts` option:
260
262
  ```tsx
261
263
  const { open } = useExpressCSV({
262
264
  schema,
263
- publishableKey: "your-publishable-key",
265
+ getSessionToken: async () => fetchSessionToken(),
264
266
  importIdentifier: "user-import",
265
267
  fonts: {
266
268
  title: { source: "google", name: "Space Grotesk", weights: [400, 600, 700] },
@@ -273,202 +275,50 @@ const { open } = useExpressCSV({
273
275
  });
274
276
  ```
275
277
 
276
- ## Webhook Delivery
278
+ ## Delivery
277
279
 
278
- Deliver data to a server endpoint instead of (or in addition to) processing locally:
280
+ ExpressCSV delivers imported data through `onData`. Your app receives validated chunks in the browser and can forward them to your backend using whatever request shape fits your stack.
279
281
 
280
282
  ```tsx
281
- import { useExpressCSV, x } from "@expresscsv/react";
282
-
283
- const schema = x.row({
284
- name: x.string().label("Full Name"),
285
- email: x.string().email().label("Email Address"),
286
- });
287
-
288
- function App() {
289
- const { open } = useExpressCSV({
290
- schema,
291
- publishableKey: "your-publishable-key",
292
- importIdentifier: "user-import",
293
- title: "Import Users",
294
- });
295
-
296
- const handleImport = () => {
297
- open({
298
- webhook: {
299
- url: "https://api.example.com/webhooks/csv-import",
300
- method: "POST",
301
- headers: {
302
- Authorization: "Bearer your-api-token",
303
- },
304
- metadata: {
305
- source: "react-app",
306
- userId: "user-123",
307
- },
308
- },
309
- onComplete: () => {
310
- console.log("Webhook delivery initiated");
311
- },
312
- onError: (error) => {
313
- console.error("Delivery error:", error);
283
+ open({
284
+ chunkSize: 500,
285
+ onData: async (chunk, next) => {
286
+ const response = await fetch("/api/import-users/chunks", {
287
+ method: "POST",
288
+ headers: {
289
+ "Content-Type": "application/json",
314
290
  },
291
+ body: JSON.stringify({
292
+ sessionId: chunk.sessionId,
293
+ chunkIdempotencyKey: chunk.chunkIdempotencyKey,
294
+ records: chunk.records,
295
+ currentChunkIndex: chunk.currentChunkIndex,
296
+ totalChunks: chunk.totalChunks,
297
+ totalRecords: chunk.totalRecords,
298
+ }),
315
299
  });
316
- };
317
-
318
- return <button onClick={handleImport}>Import CSV via Webhook</button>;
319
- }
320
- ```
321
-
322
- ### Webhook Payload Structure
323
-
324
- Each chunk is delivered as a JSON `POST` (or whichever method you configured) to your endpoint. The request body has this shape:
325
-
326
- ```typescript
327
- interface WebhookPayload {
328
- /** Imported records for this chunk, matching your schema */
329
- records: Record<string, unknown>[];
330
- /** 0-based index of the current chunk */
331
- chunkIndex: number;
332
- /** Total number of chunks in this delivery */
333
- totalChunks: number;
334
- /** Total number of records across all chunks */
335
- totalRecords: number;
336
- /** Present only if you passed `metadata` in `WebhookConfig` */
337
- metadata?: Record<string, unknown>;
338
- /** Delivery context added by ExpressCSV */
339
- delivery: {
340
- publishableKey: string;
341
- environmentName: string;
342
- environmentType: string;
343
- teamSlug: string;
344
- importIdentifier: string;
345
- deliveryId: string;
346
- timestamp: string; // ISO 8601
347
- };
348
- }
349
- ```
350
-
351
- When you already have a schema, prefer `InferWebhookPayload<typeof schema>` from `@expresscsv/schemas` instead of rewriting this object shape by hand.
352
-
353
- Example payload:
354
-
355
- ```json
356
- {
357
- "records": [
358
- { "name": "Alice Johnson", "email": "alice@example.com" },
359
- { "name": "Bob Smith", "email": "bob@example.com" }
360
- ],
361
- "chunkIndex": 0,
362
- "totalChunks": 3,
363
- "totalRecords": 2500,
364
- "metadata": {
365
- "source": "react-app",
366
- "userId": "user-123"
367
- },
368
- "delivery": {
369
- "publishableKey": "pk_live_abc123",
370
- "environmentName": "Production",
371
- "environmentType": "production",
372
- "teamSlug": "my-team",
373
- "importIdentifier": "user-import",
374
- "deliveryId": "del_abc123",
375
- "timestamp": "2026-03-02T14:30:00.000Z"
376
- }
377
- }
378
- ```
379
-
380
- The request includes a `Content-Type: application/json` header plus any custom `headers` you specified in `WebhookConfig`.
381
-
382
- ### Handling Webhooks on Your Server
383
-
384
- Your endpoint should return a **2xx** status code to acknowledge each chunk. Non-2xx responses trigger the following retry behaviour:
385
-
386
- - **5xx** and **429** responses are retried automatically (up to 5 attempts per chunk).
387
- - **4xx** responses (except 429) are treated as permanent failures and are **not** retried.
388
-
389
- Chunks are delivered **serially** — the next chunk is only sent after the previous one succeeds.
390
-
391
- If your schema lives in shared or backend code via `@expresscsv/schemas`, you can reuse the built-in webhook payload helper in your webhook handler:
392
-
393
- ```typescript
394
- import express from "express";
395
- import type { InferWebhookPayload } from "@expresscsv/schemas";
396
- import { candidateSchema } from "../shared/candidate-schema";
397
-
398
- const app = express();
399
- app.use(express.json());
400
-
401
- app.post("/webhooks/csv-import", async (req, res) => {
402
- const payload = req.body as InferWebhookPayload<typeof candidateSchema>;
403
-
404
- await importCandidates(payload.records);
405
-
406
- res.status(200).json({ ok: true });
407
- });
408
- ```
409
-
410
- Below is a minimal Express.js example:
411
-
412
- ```typescript
413
- import express from "express";
414
-
415
- const app = express();
416
- app.use(express.json());
417
-
418
- app.post("/webhooks/csv-import", async (req, res) => {
419
- const token = req.headers.authorization;
420
- if (token !== "Bearer your-api-token") {
421
- return res.status(401).json({ error: "Unauthorized" });
422
- }
423
-
424
- const { records, chunkIndex, totalChunks, totalRecords, metadata, delivery } =
425
- req.body;
426
-
427
- try {
428
- // Process the records — e.g. insert into your database
429
- await db.insertMany("users", records);
430
-
431
- console.log(
432
- `Chunk ${chunkIndex + 1}/${totalChunks} processed ` +
433
- `(${records.length} records, delivery ${delivery.deliveryId})`
434
- );
435
-
436
- res.status(200).json({ success: true });
437
- } catch (error) {
438
- // Return 500 so ExpressCSV retries this chunk
439
- console.error("Failed to process chunk:", error);
440
- res.status(500).json({ error: "Internal server error" });
441
- }
442
- });
443
300
 
444
- app.listen(3000);
445
- ```
446
-
447
- **Tips:**
448
-
449
- - Use `delivery.deliveryId` and `chunkIndex` to **deduplicate** retried chunks (the same chunk may be delivered more than once on retry).
450
- - Use `chunkIndex` and `totalChunks` to track progress and know when the full import is complete (`chunkIndex === totalChunks - 1` for the last chunk).
451
- - Store `metadata` alongside imported records if you need to correlate the import with a specific user or action in your app.
452
-
453
- ### Combined Local Callback and Webhook
301
+ if (!response.ok) {
302
+ throw new Error("Backend rejected this import chunk");
303
+ }
454
304
 
455
- ```tsx
456
- open({
457
- chunkSize: 500,
458
- onData: async (chunk, next) => {
459
- await saveToLocalDatabase(chunk.records);
460
305
  next();
461
306
  },
462
- webhook: {
463
- url: "https://api.example.com/webhooks/csv-import",
464
- headers: { Authorization: "Bearer your-api-token" },
465
- },
466
- onComplete: () => {
467
- console.log("Local processing and webhook delivery complete");
307
+ onComplete: ({ sessionId }) => {
308
+ console.log("Import complete for", sessionId);
468
309
  },
469
310
  });
470
311
  ```
471
312
 
313
+ Each delivered chunk includes:
314
+
315
+ - `records`
316
+ - `sessionId`
317
+ - `chunkIdempotencyKey`
318
+ - `currentChunkIndex`
319
+ - `totalChunks`
320
+ - `totalRecords`
321
+
472
322
  ## Advanced Example
473
323
 
474
324
  ```tsx
@@ -494,7 +344,7 @@ function CandidateImporter() {
494
344
 
495
345
  const { open, isOpen } = useExpressCSV({
496
346
  schema: candidateSchema,
497
- publishableKey: "your-publishable-key",
347
+ getSessionToken: async () => fetchSessionToken(),
498
348
  importIdentifier: "candidate-import",
499
349
  title: "Import Candidates",
500
350
  });
@@ -510,13 +360,14 @@ function CandidateImporter() {
510
360
  await importCandidates(chunk.records);
511
361
  next();
512
362
  },
513
- onComplete: () => {
514
- setStatus("Import complete!");
363
+ onComplete: ({ sessionId }) => {
364
+ setStatus(`Import complete: ${sessionId}`);
515
365
  },
516
- onError: (error) => {
517
- setStatus(`Error: ${error.message}`);
366
+ onError: (error, { sessionId }) => {
367
+ setStatus(`Error in ${sessionId}: ${error.message}`);
518
368
  },
519
- onCancel: () => {
369
+ onCancel: ({ sessionId }) => {
370
+ console.log("Cancelled session", sessionId);
520
371
  setStatus(null);
521
372
  },
522
373
  });
@@ -542,7 +393,7 @@ function useExpressCSV<TSchema>(
542
393
  options: UseExpressCSVOptions<TSchema>
543
394
  ): {
544
395
  open: (options: OpenOptions<Infer<TSchema>>) => void;
545
- widgetState: WidgetState;
396
+ importerState: ImporterState;
546
397
  isInitialising: boolean;
547
398
  isOpen: boolean;
548
399
  };
@@ -553,48 +404,48 @@ function useExpressCSV<TSchema>(
553
404
  | Option | Type | Required | Default | Description |
554
405
  |---|---|---|---|---|
555
406
  | `schema` | Schema | Yes | - | Schema definition created with `x.row()` |
556
- | `publishableKey` | `string` | Yes | - | Your publishable key from the [dashboard](https://expresscsv.com) |
407
+ | `getSessionToken` | `() => Promise<string>` | Yes | - | Async callback that asks your backend for a short-lived importer session token |
557
408
  | `importIdentifier` | `string` | Yes | - | Unique identifier for this import type |
558
- | `title` | `string` | No | - | Title shown in the widget header |
559
- | `preload` | `boolean` | No | `true` | Preload widget for instant display |
409
+ | `title` | `string` | No | - | Title shown in the importer header |
410
+ | `preload` | `boolean` | No | `true` | Preload the importer for instant display |
560
411
  | `debug` | `boolean` | No | `false` | Enable debug logging |
561
412
  | `theme` | `ECSVTheme` | No | - | Custom theme configuration |
562
413
  | `colorMode` | `ColorModePref` | No | - | Light/dark mode (`'light'`, `'dark'`, or `'system'`) |
563
- | `customCSS` | `string` | No | - | Custom CSS to inject into the widget |
414
+ | `customCSS` | `string` | No | - | Custom CSS to inject into the importer |
564
415
  | `fonts` | `Record<string, ECSVFontSource>` | No | - | Custom font sources |
565
416
  | `stepDisplay` | `'progressBar' \| 'segmented' \| 'numbered'` | No | `'progressBar'` | Step indicator style |
566
417
  | `previewSchemaBeforeUpload` | `boolean` | No | `true` | Show schema preview before upload |
418
+ | `aiColumnMatching` | `boolean` | No | `true` | Enable AI-assisted column matching |
419
+ | `aiTransform` | `boolean` | No | `true` | Enable AI-assisted transform generation |
567
420
  | `templateDownload` | `TemplateDownloadOptions<TSchema>` | No | - | Template download configuration with optional schema-typed example rows |
568
- | `saveSession` | `boolean` | No | - | Persist session state |
421
+ | `storage` | `{ type: "local" } \| { type: "custom", ... }` | No | - | Enable Recovered Sessions with the built-in local backend or a custom adapter implementing `get`, `set`, and `remove` |
569
422
  | `locale` | `DeepPartial<ExpressCSVLocaleInput>` | No | - | Localization overrides |
423
+ | `disableStatusStep` | `boolean` | No | - | Skip the success/error status screen |
570
424
 
571
425
  #### Return Value
572
426
 
573
427
  | Property | Type | Description |
574
428
  |---|---|---|
575
- | `open` | `(options: OpenOptions) => void` | Opens the widget. Requires at least one of `onData` or `webhook`. |
576
- | `widgetState` | `WidgetState` | Current widget state (reactive) |
577
- | `isInitialising` | `boolean` | `true` while the widget is initializing or opening |
578
- | `isOpen` | `boolean` | `true` while the widget is open |
429
+ | `open` | `(options: OpenOptions) => void` | Opens the importer. Requires `onData` for delivery. |
430
+ | `importerState` | `ImporterState` | Current importer lifecycle state (reactive) |
431
+ | `isInitialising` | `boolean` | `true` while the importer is initializing or opening |
432
+ | `isOpen` | `boolean` | `true` while the importer is open |
579
433
 
580
434
  ### `OpenOptions<T>`
581
435
 
582
- Options passed to `open()`. At least one of `onData` or `webhook` must be provided.
436
+ Options passed to `open()`.
583
437
 
584
438
  | Option | Type | Required | Description |
585
439
  |---|---|---|---|
586
- | `onData` | `(chunk: RecordsChunk<T>, next: () => void) => void` | * | Callback for each chunk. Call `next()` to continue. |
587
- | `webhook` | `WebhookConfig` | * | Webhook endpoint for server-side delivery |
440
+ | `onData` | `(chunk: RecordsChunk<T>, next: () => void) => void` | Yes | Callback for each delivered chunk. Call `next()` to continue. |
588
441
  | `chunkSize` | `number` | No | Records per chunk (default: 1000) |
589
- | `onComplete` | `() => void` | No | Called when all chunks have been processed |
590
- | `onCancel` | `() => void` | No | Called when the user cancels the import |
591
- | `onError` | `(error: Error) => void` | No | Called when an error occurs |
592
- | `onWidgetOpen` | `() => void` | No | Called when the widget opens |
593
- | `onWidgetClose` | `(reason: string) => void` | No | Called when the widget closes |
442
+ | `onComplete` | `(context: { sessionId: string }) => void` | No | Called when all chunks have been processed |
443
+ | `onCancel` | `(context: { sessionId: string }) => void` | No | Called when the user cancels the import |
444
+ | `onError` | `(error: Error, context: { sessionId: string }) => void` | No | Called when an error occurs |
445
+ | `onImporterOpen` | `() => void` | No | Called when the importer opens |
446
+ | `onImporterClose` | `(reason: 'user_close' \| 'cancel' \| 'complete' \| 'error') => void` | No | Called when the importer closes |
594
447
  | `onStepChange` | `(stepId, previousStepId?) => void` | No | Called when the wizard step changes |
595
448
 
596
- \* At least one of `onData` or `webhook` is required.
597
-
598
449
  ### `RecordsChunk<T>`
599
450
 
600
451
  ```typescript
@@ -603,19 +454,8 @@ interface RecordsChunk<T> {
603
454
  totalChunks: number;
604
455
  currentChunkIndex: number;
605
456
  totalRecords: number;
606
- }
607
- ```
608
-
609
- ### `WebhookConfig`
610
-
611
- ```typescript
612
- interface WebhookConfig {
613
- url: string;
614
- headers?: Record<string, string>;
615
- method?: "POST" | "PUT" | "PATCH";
616
- timeout?: number;
617
- retries?: number;
618
- metadata?: Record<string, unknown>;
457
+ sessionId: string;
458
+ chunkIdempotencyKey: string;
619
459
  }
620
460
  ```
621
461
 
@@ -623,7 +463,7 @@ interface WebhookConfig {
623
463
 
624
464
  The `x` schema builder provides a type-safe, fluent API for defining your CSV structure.
625
465
 
626
- For apps that share schemas with backend code or webhook receivers, prefer defining the schema in `@expresscsv/schemas` and importing it into your React app. That keeps schema authoring free of React and widget dependencies, while also giving your backend access to `InferWebhookPayload<typeof schema>`.
466
+ For apps that share schemas with backend code, prefer defining the schema in `@expresscsv/schemas` and importing it into your React app. That keeps schema authoring free of React and importer dependencies.
627
467
 
628
468
  #### Field Types
629
469
 
@@ -728,7 +568,7 @@ All field types support:
728
568
 
729
569
  | Modifier | Description |
730
570
  |---|---|
731
- | `.label(text)` | User-facing label shown in the widget |
571
+ | `.label(text)` | User-facing label shown in the importer |
732
572
  | `.description(text)` | Help text for the field |
733
573
  | `.example(text)` | Example value shown as placeholder |
734
574
  | `.optional()` | Makes the field optional (default is required) |
@@ -780,7 +620,7 @@ x.string().refine(
780
620
  )
781
621
  ```
782
622
 
783
- **Object-returning validator** — inline `valid`, `message`, and an optional `suggestedFix` the widget can offer the user:
623
+ **Object-returning validator** — inline `valid`, `message`, and an optional `suggestedFix` the importer can offer the user:
784
624
 
785
625
  ```tsx
786
626
  x.string().refine((value) => ({
@@ -847,7 +687,7 @@ x.string().refineBatch((values) => {
847
687
 
848
688
  ### `suggestedFix`
849
689
 
850
- Both `.refine()` and `.refineBatch()` support returning a `suggestedFix` object that the widget offers as a one-click fix for the user:
690
+ Both `.refine()` and `.refineBatch()` support returning a `suggestedFix` object that the importer offers as a one-click fix for the user:
851
691
 
852
692
  ```tsx
853
693
  interface SuggestedFix {