Commit 9442c9bc970 for woocommerce
commit 9442c9bc970b123b63d1adabb3c559af4c086820
Author: Abdalsalaam Halawa <abdalsalaamnafez@gmail.com>
Date: Fri Nov 13 09:07:52 2026 +0200
[Order Fulfillments] Add the fulfillments importer client data layer and state (#69357)
* Add the fulfillments importer client data layer and state
Types, the REST client for the prepare and run endpoints, the CSV helpers for the failed-rows export, and the wizard reducer. The webpack entry is a placeholder until the orders-screen bootstrap lands at the top of the stack.
* Use first nonblank record as CSV header
The failed-rows export took its header from the first
parsed record. A leading blank line, a comma-only row,
or a whitespace-only row then replaced the real header.
Blank records stay in the parse so error row numbers
remain the physical record numbers, including blanks
between data rows. CR-only line endings are unchanged.
* Keep the import error in its own column for short and wide rows
A row with fewer cells than the header put the reason under a data
column, and a wider row pushed it past the header. Every record is now
padded to the widest one. Also stop claiming parity with
WC_CSV_Exporter::escape_data(), which does prefix negative numbers.
* Clear the ended session on a new file and list replayed rows once
PREPARE_OK and SET_FILE now reset sessionEnded, and PREPARE_OK drops the
previous summary, so a fresh session is not treated as unrecoverable.
CHUNK_OK keys row results by row number, so a chunk the import loop sends
again after a lost response does not list the same rows twice. The last
step is renamed from 'done' to 'summary' to match its heading.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/components/steps/types.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/components/steps/types.ts
new file mode 100644
index 00000000000..38f4b2d6be1
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/components/steps/types.ts
@@ -0,0 +1,18 @@
+/**
+ * External dependencies
+ */
+import type { Dispatch } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ ImporterAction,
+ ImporterState,
+} from '../../hooks/use-importer-state';
+
+export interface StepComponentProps {
+ state: ImporterState;
+ dispatch: Dispatch< ImporterAction >;
+ onClose: () => void;
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/api.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/api.ts
new file mode 100644
index 00000000000..e9cdbb760c5
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/api.ts
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import type { ColumnMapping, PrepareResponse, RunChunkResponse } from './types';
+
+const DEFAULT_BASE = '/wc/v3/fulfillments/import';
+
+/**
+ * Use the REST base localized by PHP when present so the route stays in sync
+ * with FulfillmentsCsvImporterController. Falls back to the default if the
+ * settings object is not on the page.
+ */
+function getBase(): string {
+ const route = window.wcFulfillmentsImporterSettings?.importRoute;
+ return typeof route === 'string' && route.length > 0 ? route : DEFAULT_BASE;
+}
+
+export interface PrepareArgs {
+ file: File;
+ delimiter: string;
+ notifyCustomer: boolean;
+ updateExisting: boolean;
+ signal?: AbortSignal;
+}
+
+export interface RunArgs {
+ token: string;
+ offset: number;
+ limit: number;
+ mapping: ColumnMapping;
+ notifyCustomer: boolean;
+ updateExisting: boolean;
+ signal?: AbortSignal;
+}
+
+/**
+ * Multipart wrapper for the prepare step: stages the upload, parses headers, opens an ImportSession.
+ */
+export async function prepare( args: PrepareArgs ): Promise< PrepareResponse > {
+ const body = new FormData();
+ body.append( 'file', args.file );
+ body.append( 'delimiter', args.delimiter || ',' );
+ body.append( 'notify_customer', args.notifyCustomer ? '1' : '0' );
+ body.append( 'update_existing', args.updateExisting ? '1' : '0' );
+
+ return apiFetch< PrepareResponse >( {
+ path: `${ getBase() }/prepare`,
+ method: 'POST',
+ body,
+ signal: args.signal,
+ } );
+}
+
+/**
+ * Process a single chunk against an open session. Throws on transport or REST error.
+ */
+export async function runChunk( args: RunArgs ): Promise< RunChunkResponse > {
+ const mappingForWire: Record< string, string > = {};
+ Object.entries( args.mapping ).forEach( ( [ col, key ] ) => {
+ // Unassigned ('') and "Do not import" ('skip') columns stay off the wire.
+ if ( key && key !== 'skip' ) {
+ mappingForWire[ col ] = key;
+ }
+ } );
+
+ return apiFetch< RunChunkResponse >( {
+ path: `${ getBase() }/run`,
+ method: 'POST',
+ data: {
+ token: args.token,
+ offset: args.offset,
+ limit: args.limit,
+ mapping: mappingForWire,
+ options: {
+ notify_customer: args.notifyCustomer,
+ update_existing: args.updateExisting,
+ },
+ },
+ signal: args.signal,
+ } );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/test/api.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/test/api.test.ts
new file mode 100644
index 00000000000..9a609c29741
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/test/api.test.ts
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+/**
+ * Internal dependencies
+ */
+import { prepare, runChunk } from '../api';
+
+const mockedApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+describe( 'runChunk', () => {
+ beforeEach( () => {
+ mockedApiFetch.mockReset();
+ mockedApiFetch.mockResolvedValue( {} );
+ } );
+
+ it( 'keeps unassigned and "Do not import" columns off the wire', async () => {
+ await runChunk( {
+ token: 'tok',
+ offset: 0,
+ limit: 100,
+ mapping: {
+ 0: 'order_number',
+ 1: 'skip',
+ 2: '',
+ 3: 'tracking_number',
+ },
+ notifyCustomer: false,
+ updateExisting: true,
+ } );
+
+ expect( mockedApiFetch ).toHaveBeenCalledTimes( 1 );
+ const request = mockedApiFetch.mock.calls[ 0 ][ 0 ] as {
+ data: { mapping: Record< string, string > };
+ };
+ expect( request.data.mapping ).toEqual( {
+ 0: 'order_number',
+ 3: 'tracking_number',
+ } );
+ } );
+
+ it( 'sends the token, offset, limit and options through', async () => {
+ await runChunk( {
+ token: 'tok',
+ offset: 200,
+ limit: 100,
+ mapping: { 0: 'order_number' },
+ notifyCustomer: true,
+ updateExisting: false,
+ } );
+
+ const request = mockedApiFetch.mock.calls[ 0 ][ 0 ] as {
+ data: Record< string, unknown >;
+ };
+ expect( request.data.token ).toBe( 'tok' );
+ expect( request.data.offset ).toBe( 200 );
+ expect( request.data.limit ).toBe( 100 );
+ expect( request.data.options ).toEqual( {
+ notify_customer: true,
+ update_existing: false,
+ } );
+ } );
+} );
+
+describe( 'prepare', () => {
+ beforeEach( () => {
+ mockedApiFetch.mockReset();
+ mockedApiFetch.mockResolvedValue( {} );
+ } );
+
+ it( 'passes the abort signal through to apiFetch', async () => {
+ const controller = new AbortController();
+
+ await prepare( {
+ file: new File( [ 'a,b' ], 'a.csv', { type: 'text/csv' } ),
+ delimiter: ',',
+ notifyCustomer: false,
+ updateExisting: true,
+ signal: controller.signal,
+ } );
+
+ expect( mockedApiFetch ).toHaveBeenCalledTimes( 1 );
+ const request = mockedApiFetch.mock.calls[ 0 ][ 0 ] as {
+ path: string;
+ method: string;
+ signal?: AbortSignal;
+ };
+ expect( request.path ).toBe( '/wc/v3/fulfillments/import/prepare' );
+ expect( request.method ).toBe( 'POST' );
+ expect( request.signal ).toBe( controller.signal );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/types.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/types.ts
new file mode 100644
index 00000000000..63066f840f2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/types.ts
@@ -0,0 +1,97 @@
+export interface ImporterRowResult {
+ row: number;
+ status: 'created' | 'updated' | 'skipped' | 'failed';
+ message: string;
+ /**
+ * Raw order number value from the CSV, so failed rows can still name the
+ * order the file referred to. Current servers send it for every row;
+ * optional for rows from before the field existed.
+ */
+ order_number?: string;
+ /** Stable failure code, present on failed rows only. */
+ code?: string;
+ order_id?: number;
+ /** HPOS-aware admin edit URL, present when the order was resolved. */
+ order_edit_url?: string;
+ fulfillment_id?: number;
+ notified?: boolean;
+}
+
+export interface ImporterSummary {
+ created: number;
+ updated: number;
+ skipped: number;
+ failed: number;
+ notified: number;
+ rows: ImporterRowResult[];
+}
+
+/**
+ * Values arrive through wp_localize_script, which casts scalars to strings,
+ * so numeric fields must be coerced before use. chunkSize and maxRows are
+ * optional until the server localizes them; the client falls back to defaults.
+ */
+export interface ImporterSettings {
+ importRoute: string;
+ chunkSize?: number | string;
+ maxRows?: number | string;
+ autoOpen?: boolean | string;
+ providers: Array< { key: string; label: string } >;
+}
+
+export type CanonicalColumnKey =
+ | 'order_number'
+ | 'tracking_number'
+ | 'shipment_provider'
+ | 'tracking_url'
+ | 'items'
+ | '';
+
+/**
+ * What a CSV column can be mapped to in the UI. The empty string means the
+ * column is still unassigned; 'skip' means the merchant chose "Do not import".
+ * The distinction lets the mapping screen flag only genuinely unassigned
+ * columns when a required field is missing. Neither value is sent on the wire.
+ */
+export type MappingChoice = CanonicalColumnKey | 'skip';
+
+/**
+ * CSV column index => mapping choice.
+ */
+export type ColumnMapping = Record< number, MappingChoice >;
+
+export interface PrepareResponse {
+ token: string;
+ headers: string[];
+ sample: string[];
+ total: number;
+ /**
+ * The server returns string-keyed indices ("0", "1", ...) per the REST contract.
+ */
+ detected_mapping: Record< string, CanonicalColumnKey >;
+ delimiter: string;
+}
+
+export interface RunChunkError {
+ row: number;
+ code: string;
+ message: string;
+}
+
+export interface RunChunkCounts {
+ created: number;
+ updated: number;
+ skipped: number;
+ failed: number;
+ notified: number;
+}
+
+export interface RunChunkResponse {
+ processed: number;
+ total: number;
+ done: boolean;
+ counts: RunChunkCounts;
+ rows: ImporterRowResult[];
+ errors: RunChunkError[];
+ summary?: ImporterSummary;
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/global.d.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/global.d.ts
new file mode 100644
index 00000000000..48e51337eb7
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/global.d.ts
@@ -0,0 +1,9 @@
+import type { ImporterSettings } from './data/types';
+
+declare global {
+ interface Window {
+ wcFulfillmentsImporterSettings?: ImporterSettings;
+ }
+}
+
+export {};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/test/use-importer-state.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/test/use-importer-state.test.ts
new file mode 100644
index 00000000000..8133471f104
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/test/use-importer-state.test.ts
@@ -0,0 +1,382 @@
+/**
+ * Internal dependencies
+ */
+import {
+ createInitialState,
+ hasAllRequiredColumns,
+ importerReducer,
+} from '../use-importer-state';
+import type {
+ ImporterSummary,
+ PrepareResponse,
+ RunChunkResponse,
+} from '../../data/types';
+
+const prepareResponse: PrepareResponse = {
+ token: 'abc',
+ headers: [ 'Order ID', 'Tracking', 'Carrier' ],
+ sample: [ '12345', '1Z999', 'UPS' ],
+ total: 25,
+ detected_mapping: {
+ '0': 'order_number',
+ '1': 'tracking_number',
+ '2': 'shipment_provider',
+ },
+ delimiter: ',',
+};
+
+const chunkResponse: RunChunkResponse = {
+ processed: 10,
+ total: 25,
+ done: false,
+ counts: { created: 8, updated: 1, skipped: 1, failed: 0, notified: 0 },
+ rows: [],
+ errors: [],
+};
+
+const summary: ImporterSummary = {
+ created: 22,
+ updated: 1,
+ skipped: 1,
+ failed: 1,
+ notified: 0,
+ rows: [],
+};
+
+describe( 'importerReducer', () => {
+ it( 'transitions upload → mapping → import → summary on the happy path', () => {
+ let state = createInitialState();
+ expect( state.step ).toBe( 'upload' );
+
+ state = importerReducer( state, {
+ type: 'SET_FILE',
+ file: new File( [ 'a,b' ], 'a.csv' ),
+ } );
+ expect( state.file?.name ).toBe( 'a.csv' );
+
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+ expect( state.step ).toBe( 'mapping' );
+ expect( state.token ).toBe( 'abc' );
+ expect( state.mapping[ 0 ] ).toBe( 'order_number' );
+
+ state = importerReducer( state, { type: 'GO_IMPORT' } );
+ expect( state.step ).toBe( 'import' );
+
+ state = importerReducer( state, {
+ type: 'CHUNK_OK',
+ payload: chunkResponse,
+ } );
+ expect( state.processed ).toBe( 10 );
+ expect( state.counts.created ).toBe( 8 );
+
+ state = importerReducer( state, { type: 'FINISH', summary } );
+ expect( state.step ).toBe( 'summary' );
+ expect( state.summary?.created ).toBe( 22 );
+ } );
+
+ it( 'refuses to leave mapping when required columns are not mapped', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+ state = importerReducer( state, {
+ type: 'SET_MAPPING_FOR_COL',
+ col: 2,
+ value: '',
+ } );
+
+ const before = state.step;
+ state = importerReducer( state, { type: 'GO_IMPORT' } );
+ expect( state.step ).toBe( before );
+ expect( state.step ).toBe( 'mapping' );
+ } );
+
+ it( 'ERROR clears busy and stores the message; RESET wipes everything', () => {
+ let state = createInitialState();
+ state = importerReducer( state, { type: 'SET_BUSY', value: true } );
+ state = importerReducer( state, {
+ type: 'ERROR',
+ message: 'Upload failed',
+ } );
+
+ expect( state.isBusy ).toBe( false );
+ expect( state.error ).toBe( 'Upload failed' );
+
+ state = importerReducer( state, { type: 'RESET' } );
+ expect( state ).toEqual( { ...createInitialState(), generation: 1 } );
+ } );
+
+ it( 'RESET and BACK_TO_UPLOAD start a new generation', () => {
+ let state = createInitialState();
+ expect( state.generation ).toBe( 0 );
+
+ state = importerReducer( state, { type: 'RESET' } );
+ expect( state.generation ).toBe( 1 );
+
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+ expect( state.generation ).toBe( 1 );
+
+ state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+ expect( state.generation ).toBe( 2 );
+ } );
+
+ it( 'a new file and a new session clear sessionEnded and the old summary', () => {
+ let state = importerReducer( createInitialState(), {
+ type: 'FINISH',
+ summary,
+ } );
+ state = importerReducer( state, {
+ type: 'ERROR',
+ message: 'The import session expired.',
+ sessionEnded: true,
+ } );
+
+ state = importerReducer( state, {
+ type: 'SET_FILE',
+ file: new File( [ 'a,b' ], 'a.csv' ),
+ } );
+ expect( state.sessionEnded ).toBe( false );
+
+ state = importerReducer( state, {
+ type: 'ERROR',
+ message: 'The import session expired.',
+ sessionEnded: true,
+ } );
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+ expect( state.step ).toBe( 'mapping' );
+ expect( state.error ).toBeNull();
+ expect( state.sessionEnded ).toBe( false );
+ expect( state.summary ).toBeNull();
+ } );
+
+ it( 'CHUNK_OK lists a row only once when a chunk is sent again', () => {
+ const failedRow = {
+ row: 2,
+ status: 'failed' as const,
+ code: 'order_not_found',
+ message: 'Order not found.',
+ };
+ const createdRow = {
+ row: 3,
+ status: 'created' as const,
+ message: '',
+ };
+ const chunk: RunChunkResponse = {
+ ...chunkResponse,
+ processed: 2,
+ total: 2,
+ counts: {
+ created: 1,
+ updated: 0,
+ skipped: 0,
+ failed: 1,
+ notified: 0,
+ },
+ rows: [ failedRow, createdRow ],
+ };
+
+ let state = importerReducer( createInitialState(), {
+ type: 'CHUNK_OK',
+ payload: chunk,
+ } );
+ state = importerReducer( state, { type: 'CHUNK_OK', payload: chunk } );
+ expect( state.rows ).toEqual( [ failedRow, createdRow ] );
+
+ state = importerReducer( state, {
+ type: 'FINISH',
+ summary: { ...summary, created: 1, failed: 1 },
+ } );
+ expect( state.summary?.rows ).toHaveLength( 2 );
+ } );
+
+ it( 'SET_FILE ends the busy state of a preparation in flight', () => {
+ let state = createInitialState();
+ state = importerReducer( state, { type: 'SET_BUSY', value: true } );
+ state = importerReducer( state, {
+ type: 'SET_FILE',
+ file: new File( [ '' ], 'b.csv', { type: 'text/csv' } ),
+ } );
+
+ expect( state.isBusy ).toBe( false );
+ } );
+
+ it( 'SET_MAPPING_FOR_COL clears any other column mapped to the same field', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+
+ state = importerReducer( state, {
+ type: 'SET_MAPPING_FOR_COL',
+ col: 2,
+ value: 'tracking_number',
+ } );
+
+ expect( state.mapping[ 2 ] ).toBe( 'tracking_number' );
+ expect( state.mapping[ 1 ] ).toBe( '' );
+ expect( state.mapping[ 0 ] ).toBe( 'order_number' );
+ } );
+
+ it( 'SET_MAPPING_FOR_COL treats unknown values as "do not import"', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+
+ state = importerReducer( state, {
+ type: 'SET_MAPPING_FOR_COL',
+ col: 1,
+ value: 'not_a_column' as never,
+ } );
+
+ expect( state.mapping[ 1 ] ).toBe( '' );
+ } );
+
+ it( 'SET_MAPPING_FOR_COL allows several columns set to "Do not import"', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+
+ state = importerReducer( state, {
+ type: 'SET_MAPPING_FOR_COL',
+ col: 1,
+ value: 'skip',
+ } );
+ state = importerReducer( state, {
+ type: 'SET_MAPPING_FOR_COL',
+ col: 2,
+ value: 'skip',
+ } );
+
+ expect( state.mapping[ 1 ] ).toBe( 'skip' );
+ expect( state.mapping[ 2 ] ).toBe( 'skip' );
+ } );
+
+ it( 'SET_FILE clears the cached file text', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'SET_FILE_TEXT',
+ text: 'a,b\n1,2\n',
+ } );
+ expect( state.fileText ).toBe( 'a,b\n1,2\n' );
+
+ // A newly chosen file must never export rows from the old one.
+ state = importerReducer( state, {
+ type: 'SET_FILE',
+ file: new File( [ 'x,y' ], 'other.csv' ),
+ } );
+ expect( state.fileText ).toBeNull();
+ } );
+
+ it( 'PREPARE_OK stores the delimiter the server parsed with', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: { ...prepareResponse, delimiter: ';' },
+ } );
+ expect( state.delimiter ).toBe( ';' );
+
+ // An empty delimiter in the response keeps the current one.
+ state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: { ...prepareResponse, delimiter: '' },
+ } );
+ expect( state.delimiter ).toBe( ';' );
+ } );
+
+ it( 'BACK_TO_UPLOAD returns to the upload step and keeps the mapping', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+ state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+
+ expect( state.step ).toBe( 'upload' );
+ expect( state.mapping[ 0 ] ).toBe( 'order_number' );
+ } );
+
+ it( 'PREPARE_OK keeps manual mapping edits when the headers are unchanged', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+ state = importerReducer( state, {
+ type: 'SET_MAPPING_FOR_COL',
+ col: 2,
+ value: 'skip',
+ } );
+ state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: prepareResponse,
+ } );
+
+ expect( state.mapping[ 2 ] ).toBe( 'skip' );
+
+ // A file with different headers resets to the fresh detection.
+ state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: {
+ ...prepareResponse,
+ headers: [ 'Other', 'Headers', 'Here' ],
+ },
+ } );
+ expect( state.mapping[ 2 ] ).toBe( 'shipment_provider' );
+ } );
+
+ it( 'PREPARE_OK drops duplicate detected fields, first column wins', () => {
+ let state = createInitialState();
+ state = importerReducer( state, {
+ type: 'PREPARE_OK',
+ payload: {
+ ...prepareResponse,
+ detected_mapping: {
+ '0': 'order_number',
+ '1': 'tracking_number',
+ '2': 'tracking_number',
+ },
+ },
+ } );
+
+ expect( state.mapping ).toEqual( {
+ 0: 'order_number',
+ 1: 'tracking_number',
+ 2: '',
+ } );
+ } );
+
+ it( 'hasAllRequiredColumns is true only when every required column is mapped', () => {
+ expect( hasAllRequiredColumns( {} ) ).toBe( false );
+ expect(
+ hasAllRequiredColumns( {
+ 0: 'order_number',
+ 1: 'tracking_number',
+ } )
+ ).toBe( false );
+ expect(
+ hasAllRequiredColumns( {
+ 0: 'order_number',
+ 1: 'tracking_number',
+ 2: 'shipment_provider',
+ } )
+ ).toBe( true );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/use-importer-state.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/use-importer-state.ts
new file mode 100644
index 00000000000..42c7604488d
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/use-importer-state.ts
@@ -0,0 +1,357 @@
+/**
+ * External dependencies
+ */
+import { useReducer } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ CanonicalColumnKey,
+ ColumnMapping,
+ ImporterRowResult,
+ ImporterSummary,
+ MappingChoice,
+ PrepareResponse,
+ RunChunkResponse,
+} from '../data/types';
+
+export type ImporterStep = 'upload' | 'mapping' | 'import' | 'summary';
+
+export interface ImporterState {
+ step: ImporterStep;
+ // Upload step.
+ file: File | null;
+ // Content of the staged file, read at upload time. The File handle only
+ // references the on-disk file, so reading it later fails if the file was
+ // moved or edited; the failed-rows export works from this copy instead.
+ fileText: string | null;
+ delimiter: string;
+ notifyCustomer: boolean;
+ updateExisting: boolean;
+ // Mapping step.
+ token: string | null;
+ headers: string[];
+ sample: string[];
+ total: number;
+ mapping: ColumnMapping;
+ // Import step.
+ processed: number;
+ counts: {
+ created: number;
+ updated: number;
+ skipped: number;
+ failed: number;
+ notified: number;
+ };
+ rows: ImporterRowResult[];
+ // Summary step.
+ summary: ImporterSummary | null;
+ // Ambient.
+ error: string | null;
+ // True when the error ended the server-side session, so retrying cannot succeed.
+ sessionEnded: boolean;
+ isBusy: boolean;
+ // Bumped whenever the wizard restarts, so an async result started under an
+ // earlier session can tell it no longer belongs to the current state.
+ generation: number;
+}
+
+export type ImporterAction =
+ | { type: 'SET_FILE'; file: File | null }
+ | { type: 'SET_FILE_TEXT'; text: string | null }
+ | { type: 'SET_DELIMITER'; delimiter: string }
+ | { type: 'SET_NOTIFY'; value: boolean }
+ | { type: 'SET_UPDATE_EXISTING'; value: boolean }
+ | { type: 'SET_BUSY'; value: boolean }
+ | { type: 'PREPARE_OK'; payload: PrepareResponse }
+ | { type: 'SET_MAPPING_FOR_COL'; col: number; value: MappingChoice }
+ | { type: 'BACK_TO_UPLOAD' }
+ | { type: 'GO_IMPORT' }
+ | { type: 'CHUNK_OK'; payload: RunChunkResponse }
+ | { type: 'FINISH'; summary: ImporterSummary }
+ | { type: 'ERROR'; message: string; sessionEnded?: boolean }
+ | { type: 'CLEAR_ERROR' }
+ | { type: 'RESET' };
+
+export const REQUIRED_COLUMNS: CanonicalColumnKey[] = [
+ 'order_number',
+ 'tracking_number',
+ 'shipment_provider',
+];
+
+const CANONICAL_COLUMN_KEYS: ReadonlySet< CanonicalColumnKey > = new Set( [
+ '',
+ 'order_number',
+ 'tracking_number',
+ 'shipment_provider',
+ 'tracking_url',
+ 'items',
+] );
+
+const MAPPING_CHOICES: ReadonlySet< MappingChoice > = new Set( [
+ ...CANONICAL_COLUMN_KEYS,
+ 'skip',
+] );
+
+// Values that do not map a column to a field, so any number of columns can hold them.
+function isNonField( value: MappingChoice ): boolean {
+ return value === '' || value === 'skip';
+}
+
+export function createInitialState(): ImporterState {
+ return {
+ step: 'upload',
+ file: null,
+ fileText: null,
+ delimiter: ',',
+ notifyCustomer: false,
+ updateExisting: true,
+ token: null,
+ headers: [],
+ sample: [],
+ total: 0,
+ mapping: {},
+ processed: 0,
+ counts: {
+ created: 0,
+ updated: 0,
+ skipped: 0,
+ failed: 0,
+ notified: 0,
+ },
+ rows: [],
+ summary: null,
+ error: null,
+ sessionEnded: false,
+ isBusy: false,
+ generation: 0,
+ };
+}
+
+/**
+ * Drop repeated canonical fields from a detected mapping, keeping the first
+ * column (lowest index) that maps to each field.
+ */
+function uniqueMapping( mapping: ColumnMapping ): ColumnMapping {
+ const taken = new Set< MappingChoice >();
+ const next: ColumnMapping = {};
+ Object.keys( mapping )
+ .map( Number )
+ .sort( ( a, b ) => a - b )
+ .forEach( ( col ) => {
+ const value = mapping[ col ];
+ if ( ! isNonField( value ) && taken.has( value ) ) {
+ next[ col ] = '';
+ return;
+ }
+ taken.add( value );
+ next[ col ] = value;
+ } );
+ return next;
+}
+
+function normalizeMapping(
+ wireMapping: PrepareResponse[ 'detected_mapping' ]
+): ColumnMapping {
+ const out: ColumnMapping = {};
+ Object.entries( wireMapping || {} ).forEach( ( [ col, key ] ) => {
+ const candidate = ( key || '' ) as CanonicalColumnKey;
+ out[ Number( col ) ] = CANONICAL_COLUMN_KEYS.has( candidate )
+ ? candidate
+ : '';
+ } );
+ return uniqueMapping( out );
+}
+
+export function hasAllRequiredColumns( mapping: ColumnMapping ): boolean {
+ const present = new Set( Object.values( mapping ).filter( Boolean ) );
+ return REQUIRED_COLUMNS.every( ( required ) => present.has( required ) );
+}
+
+/**
+ * Assign a field to one CSV column, keeping field mappings one-to-one: any
+ * other column currently mapped to the same field is cleared, and values
+ * outside the known set are treated as unassigned.
+ */
+function assignMappingForCol(
+ mapping: ColumnMapping,
+ col: number,
+ value: MappingChoice
+): ColumnMapping {
+ const safeValue = MAPPING_CHOICES.has( value ) ? value : '';
+ const next: ColumnMapping = {};
+ Object.entries( mapping ).forEach( ( [ key, mapped ] ) => {
+ const index = Number( key );
+ next[ index ] =
+ ! isNonField( safeValue ) && mapped === safeValue && index !== col
+ ? ''
+ : mapped;
+ } );
+ next[ col ] = safeValue;
+ return next;
+}
+
+/**
+ * Add a chunk's row results, keyed by physical row number. A chunk the
+ * import loop sends again after a lost response reports the same rows, so a
+ * repeated row replaces the earlier entry instead of being listed twice.
+ */
+function mergeRowResults(
+ existing: ImporterRowResult[],
+ incoming: ImporterRowResult[]
+): ImporterRowResult[] {
+ const byRow = new Map< number, ImporterRowResult >();
+ existing.forEach( ( result ) => byRow.set( result.row, result ) );
+ incoming.forEach( ( result ) => byRow.set( result.row, result ) );
+ return Array.from( byRow.values() );
+}
+
+export function importerReducer(
+ state: ImporterState,
+ action: ImporterAction
+): ImporterState {
+ switch ( action.type ) {
+ case 'SET_FILE':
+ // Picking a file aborts any preparation in flight, so busy ends here.
+ return {
+ ...state,
+ file: action.file,
+ fileText: null,
+ error: null,
+ sessionEnded: false,
+ isBusy: false,
+ };
+ case 'SET_FILE_TEXT':
+ return { ...state, fileText: action.text };
+ case 'SET_DELIMITER':
+ return { ...state, delimiter: action.delimiter };
+ case 'SET_NOTIFY':
+ return { ...state, notifyCustomer: action.value };
+ case 'SET_UPDATE_EXISTING':
+ return { ...state, updateExisting: action.value };
+ case 'SET_BUSY':
+ return { ...state, isBusy: action.value };
+ case 'PREPARE_OK': {
+ const detected = normalizeMapping(
+ action.payload.detected_mapping
+ );
+ // Keep the merchant's manual mapping when they went back to the
+ // upload step and continued with a file of the same shape.
+ const sameHeaders =
+ state.headers.length > 0 &&
+ state.headers.length === action.payload.headers.length &&
+ state.headers.every(
+ ( header, index ) =>
+ header === action.payload.headers[ index ]
+ );
+ return {
+ ...state,
+ step: 'mapping',
+ token: action.payload.token,
+ // Keep the delimiter the server actually parsed with, so the
+ // failed-rows export splits the file the same way.
+ delimiter: action.payload.delimiter || state.delimiter,
+ headers: action.payload.headers,
+ sample: action.payload.sample,
+ total: action.payload.total,
+ mapping: sameHeaders ? state.mapping : detected,
+ processed: 0,
+ counts: {
+ created: 0,
+ updated: 0,
+ skipped: 0,
+ failed: 0,
+ notified: 0,
+ },
+ rows: [],
+ summary: null,
+ error: null,
+ sessionEnded: false,
+ isBusy: false,
+ };
+ }
+ case 'SET_MAPPING_FOR_COL':
+ return {
+ ...state,
+ mapping: assignMappingForCol(
+ state.mapping,
+ action.col,
+ action.value
+ ),
+ };
+ case 'BACK_TO_UPLOAD':
+ // The staged file, delimiter and mapping are all kept so the
+ // merchant can swap the file or a setting and continue.
+ return {
+ ...state,
+ step: 'upload',
+ error: null,
+ sessionEnded: false,
+ generation: state.generation + 1,
+ };
+ case 'GO_IMPORT':
+ if ( ! hasAllRequiredColumns( state.mapping ) ) {
+ return state;
+ }
+ return {
+ ...state,
+ step: 'import',
+ processed: 0,
+ counts: {
+ created: 0,
+ updated: 0,
+ skipped: 0,
+ failed: 0,
+ notified: 0,
+ },
+ rows: [],
+ error: null,
+ };
+ case 'CHUNK_OK':
+ return {
+ ...state,
+ processed: action.payload.processed,
+ counts: {
+ created: action.payload.counts.created,
+ updated: action.payload.counts.updated,
+ skipped: action.payload.counts.skipped,
+ failed: action.payload.counts.failed,
+ notified: action.payload.counts.notified,
+ },
+ rows: mergeRowResults( state.rows, action.payload.rows ?? [] ),
+ error: null,
+ };
+ case 'FINISH':
+ return {
+ ...state,
+ step: 'summary',
+ summary: {
+ ...action.summary,
+ rows: state.rows,
+ },
+ isBusy: false,
+ error: null,
+ };
+ case 'ERROR':
+ return {
+ ...state,
+ error: action.message,
+ sessionEnded: action.sessionEnded === true,
+ isBusy: false,
+ };
+ case 'CLEAR_ERROR':
+ return { ...state, error: null, sessionEnded: false };
+ case 'RESET':
+ return {
+ ...createInitialState(),
+ generation: state.generation + 1,
+ };
+ default:
+ return state;
+ }
+}
+
+export function useImporterState() {
+ return useReducer( importerReducer, undefined, createInitialState );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/index.tsx
new file mode 100644
index 00000000000..1e64440c5ab
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/index.tsx
@@ -0,0 +1,8 @@
+/**
+ * Entry point of the fulfillments importer bundle.
+ *
+ * Every folder under wp-admin-scripts is a webpack entry, so this file has to
+ * exist from the first PR of the stack. The orders-screen bootstrap (trigger
+ * button, auto-open handling and the wizard mount) replaces it in the last PR.
+ */
+export {};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/csv.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/csv.ts
new file mode 100644
index 00000000000..45bc1b07792
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/csv.ts
@@ -0,0 +1,166 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ImporterRowResult } from '../data/types';
+
+/**
+ * Trigger a browser download for a CSV text.
+ */
+export function downloadCsv( filename: string, content: string ): void {
+ const blob = new Blob( [ content ], { type: 'text/csv' } );
+ const url = URL.createObjectURL( blob );
+ const link = document.createElement( 'a' );
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild( link );
+ link.click();
+ document.body.removeChild( link );
+ // Revoking in the same tick can cancel the download in Safari and Firefox.
+ setTimeout( () => URL.revokeObjectURL( url ), 1000 );
+}
+
+/**
+ * Split CSV text into records, honoring quoted fields (including embedded
+ * delimiters, escaped quotes and newlines). Indexes match the importer's
+ * physical record numbers, so blank lines stay in the result.
+ */
+export function parseCsvRecords( text: string, delimiter: string ): string[][] {
+ // A quote delimiter would collide with the quoting rules below.
+ if ( delimiter === '"' ) {
+ delimiter = ',';
+ }
+ // Strip a UTF-8 BOM so the first header cell round-trips clean.
+ if ( text.charCodeAt( 0 ) === 0xfeff ) {
+ text = text.slice( 1 );
+ }
+
+ const records: string[][] = [];
+ let record: string[] = [];
+ let field = '';
+ let inQuotes = false;
+
+ for ( let i = 0; i < text.length; i++ ) {
+ const char = text[ i ];
+ if ( inQuotes ) {
+ if ( char === '"' ) {
+ if ( text[ i + 1 ] === '"' ) {
+ field += '"';
+ i++;
+ } else {
+ inQuotes = false;
+ }
+ } else {
+ field += char;
+ }
+ } else if ( char === '"' && field === '' ) {
+ inQuotes = true;
+ } else if ( char === delimiter ) {
+ record.push( field );
+ field = '';
+ } else if ( char === '\n' || char === '\r' ) {
+ if ( char === '\r' && text[ i + 1 ] === '\n' ) {
+ i++;
+ }
+ record.push( field );
+ records.push( record );
+ record = [];
+ field = '';
+ } else {
+ field += char;
+ }
+ }
+ if ( field !== '' || record.length > 0 ) {
+ record.push( field );
+ records.push( record );
+ }
+ return records;
+}
+
+// A leading trigger character can run as a formula when the file is opened in
+// a spreadsheet. Unlike WC_CSV_Exporter::escape_data(), numeric text such as
+// `-12.5` is left alone, so negative numbers stay numbers.
+const ACTIVE_CONTENT_TRIGGERS = [ '=', '+', '-', '@', '\t', '\r' ];
+
+function escapeCsvField( value: string, delimiter: string ): string {
+ if (
+ ACTIVE_CONTENT_TRIGGERS.includes( value[ 0 ] ) &&
+ Number.isNaN( Number( value ) )
+ ) {
+ value = `'${ value }`;
+ }
+ if (
+ value.includes( '"' ) ||
+ value.includes( delimiter ) ||
+ value.includes( '\n' ) ||
+ value.includes( '\r' )
+ ) {
+ return `"${ value.replace( /"/g, '""' ) }"`;
+ }
+ return value;
+}
+
+/**
+ * A record is blank when every cell is empty once trimmed, including a
+ * line of commas (`,,,`) or whitespace. Matches the server reader.
+ */
+function isBlankRecord( record: string[] ): boolean {
+ return record.every( ( cell ) => cell.trim() === '' );
+}
+
+/**
+ * Build a CSV of the failed rows in their original columns plus a reason
+ * column, ready to send back to whoever produced the file. `row` is the
+ * 1-based physical record number, including blank records. The header is
+ * the first record that is not blank.
+ */
+export function buildFailedRowsCsv(
+ fileText: string,
+ delimiter: string,
+ failedRows: ImporterRowResult[]
+): string {
+ const effectiveDelimiter = delimiter || ',';
+ const records = parseCsvRecords( fileText, effectiveDelimiter );
+ const header =
+ records.find( ( record ) => ! isBlankRecord( record ) ) ?? [];
+
+ // Physical index, including blank records. A missing index means the
+ // report names a row the file does not have.
+ const failedRecords = failedRows.map( ( row ) => ( {
+ record: records[ row.row - 1 ] ?? [
+ __( '(original row unavailable)', 'woocommerce' ),
+ ],
+ message: row.message,
+ } ) );
+
+ // Pad every record to the widest one so the reason always lands in the
+ // "Import error" column, also for rows shorter or wider than the header.
+ const width = Math.max(
+ header.length,
+ ...failedRecords.map( ( { record } ) => record.length )
+ );
+ const pad = ( record: string[] ): string[] => [
+ ...record,
+ ...Array< string >( width - record.length ).fill( '' ),
+ ];
+
+ const out: string[][] = [
+ [ ...pad( header ), __( 'Import error', 'woocommerce' ) ],
+ ...failedRecords.map( ( { record, message } ) => [
+ ...pad( record ),
+ message,
+ ] ),
+ ];
+
+ return out
+ .map( ( record ) =>
+ record
+ .map( ( field ) => escapeCsvField( field, effectiveDelimiter ) )
+ .join( effectiveDelimiter )
+ )
+ .join( '\r\n' );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/test/csv.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/test/csv.test.ts
new file mode 100644
index 00000000000..02d9893b2fe
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/test/csv.test.ts
@@ -0,0 +1,256 @@
+/**
+ * Internal dependencies
+ */
+import { buildFailedRowsCsv, parseCsvRecords } from '../csv';
+import type { ImporterRowResult } from '../../data/types';
+
+function failedRow(
+ row: number,
+ message = 'Order not found for order number "9".'
+): ImporterRowResult {
+ return {
+ row,
+ status: 'failed',
+ code: 'order_not_found',
+ message,
+ order_number: '9',
+ };
+}
+
+describe( 'parseCsvRecords', () => {
+ it( 'splits simple rows on the delimiter', () => {
+ expect( parseCsvRecords( 'a,b,c\n1,2,3', ',' ) ).toEqual( [
+ [ 'a', 'b', 'c' ],
+ [ '1', '2', '3' ],
+ ] );
+ } );
+
+ it( 'returns no records for empty input', () => {
+ expect( parseCsvRecords( '', ',' ) ).toEqual( [] );
+ } );
+
+ it( 'does not emit an empty record for a trailing newline', () => {
+ expect( parseCsvRecords( 'a,b\n1,2\n', ',' ) ).toEqual( [
+ [ 'a', 'b' ],
+ [ '1', '2' ],
+ ] );
+ } );
+
+ it( 'honors quoted fields with embedded delimiters, quotes and newlines', () => {
+ const text = 'a,b\n"1,5","he said ""hi""\nsecond line"';
+ expect( parseCsvRecords( text, ',' ) ).toEqual( [
+ [ 'a', 'b' ],
+ [ '1,5', 'he said "hi"\nsecond line' ],
+ ] );
+ } );
+
+ it( 'falls back to a comma when the delimiter is the quote character', () => {
+ expect( parseCsvRecords( 'a,b\n"x,y",2', '"' ) ).toEqual( [
+ [ 'a', 'b' ],
+ [ 'x,y', '2' ],
+ ] );
+ } );
+
+ it( 'strips a UTF-8 BOM and handles CRLF line endings', () => {
+ expect( parseCsvRecords( 'a;b\r\n1;2\r\n', ';' ) ).toEqual( [
+ [ 'a', 'b' ],
+ [ '1', '2' ],
+ ] );
+ } );
+
+ it( 'treats a lone CR as a line ending', () => {
+ expect( parseCsvRecords( 'a,b\r1,2\r3,4', ',' ) ).toEqual( [
+ [ 'a', 'b' ],
+ [ '1', '2' ],
+ [ '3', '4' ],
+ ] );
+ } );
+
+ it( 'keeps blank, comma-only and whitespace-only records at their physical indexes', () => {
+ expect(
+ parseCsvRecords(
+ '\n,,,\norder,tracking\n \n" \t ", \n1,2\n',
+ ','
+ )
+ ).toEqual( [
+ [ '' ],
+ [ '', '', '', '' ],
+ [ 'order', 'tracking' ],
+ [ ' ' ],
+ [ ' \t ', ' ' ],
+ [ '1', '2' ],
+ ] );
+ } );
+} );
+
+describe( 'buildFailedRowsCsv', () => {
+ const failed: ImporterRowResult[] = [
+ {
+ row: 3,
+ status: 'failed',
+ code: 'order_not_found',
+ message: 'Order not found for order number "9".',
+ order_number: '9',
+ },
+ ];
+
+ it( 'keeps the original columns and appends the reason', () => {
+ const file = 'order,tracking\n1,T-1\n9,T-2\n';
+ const csv = buildFailedRowsCsv( file, ',', failed );
+ const lines = csv.split( '\r\n' );
+
+ expect( lines[ 0 ] ).toBe( 'order,tracking,Import error' );
+ // Row 3 of the file (header is row 1) is the failed one.
+ expect( lines[ 1 ] ).toBe(
+ '9,T-2,"Order not found for order number ""9""."'
+ );
+ expect( lines ).toHaveLength( 2 );
+ } );
+
+ it( 'quotes a reason that contains the semicolon delimiter', () => {
+ const semicolonFailed: ImporterRowResult[] = [
+ {
+ row: 3,
+ status: 'failed',
+ code: 'order_not_found',
+ message: 'Order not found; check the number.',
+ order_number: '9',
+ },
+ ];
+ const csv = buildFailedRowsCsv(
+ 'order;tracking\n1;T-1\n9;T-2\n',
+ ';',
+ semicolonFailed
+ );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order;tracking;Import error',
+ '9;T-2;"Order not found; check the number."',
+ ] );
+ } );
+
+ it( 'neutralizes spreadsheet formula triggers but not plain numbers', () => {
+ const file = 'order,note\n1,x\n9,=HYPERLINK("http://evil")\n';
+ const csv = buildFailedRowsCsv( file, ',', failed );
+
+ expect( csv ).toContain( `"'=HYPERLINK(""http://evil"")"` );
+ // Negative numbers stay untouched. WC_CSV_Exporter::escape_data() would
+ // prefix this one, since fgetcsv() hands it a string.
+ const numeric = buildFailedRowsCsv(
+ 'order,amount\n1,x\n9,-12.5\n',
+ ',',
+ failed
+ );
+ expect( numeric ).toContain( '9,-12.5,' );
+ } );
+
+ it( 'pads a row shorter than the header so the reason stays in its column', () => {
+ const shortFailed: ImporterRowResult[] = [
+ {
+ row: 3,
+ status: 'failed',
+ code: 'missing_provider',
+ message: 'Shipment provider is required',
+ order_number: '1002',
+ },
+ ];
+ const csv = buildFailedRowsCsv(
+ 'order_number,tracking_number,shipment_provider,tracking_url\n1001,TRK-1,ups,\n1002,TRK-2\n',
+ ',',
+ shortFailed
+ );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order_number,tracking_number,shipment_provider,tracking_url,Import error',
+ '1002,TRK-2,,,Shipment provider is required',
+ ] );
+ } );
+
+ it( 'widens the header when a row has more cells than the header', () => {
+ const csv = buildFailedRowsCsv(
+ 'order,tracking\n1,T-1\n9,T-2,extra\n',
+ ',',
+ failed
+ );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order,tracking,,Import error',
+ '9,T-2,extra,"Order not found for order number ""9""."',
+ ] );
+ } );
+
+ it( 'marks rows whose original record cannot be found', () => {
+ const csv = buildFailedRowsCsv( 'order,tracking\n', ',', failed );
+ expect( csv.split( '\r\n' )[ 1 ] ).toContain(
+ '(original row unavailable)'
+ );
+ } );
+
+ it( 'uses the first nonblank record as the header after a leading blank line', () => {
+ const csv = buildFailedRowsCsv(
+ '\norder,tracking\n1,T-1\n9,T-2\n',
+ ',',
+ [ failedRow( 4 ) ]
+ );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order,tracking,Import error',
+ '9,T-2,"Order not found for order number ""9""."',
+ ] );
+ } );
+
+ it( 'treats a leading comma-only row as blank', () => {
+ const csv = buildFailedRowsCsv(
+ ',,,\norder,tracking\n1,T-1\n9,T-2\n',
+ ',',
+ [ failedRow( 4 ) ]
+ );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order,tracking,Import error',
+ '9,T-2,"Order not found for order number ""9""."',
+ ] );
+ } );
+
+ it( 'treats whitespace-only rows as blank and does not trim real cells', () => {
+ // Physical rows: spaces, quoted tab, header, comma-separated
+ // whitespace, then the failed row with its own surrounding spaces.
+ const csv = buildFailedRowsCsv(
+ ' \n" \t "\norder,tracking\n , \t , \n 9 , FAIL \n',
+ ',',
+ [ failedRow( 5, 'Missing order' ) ]
+ );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order,tracking,Import error',
+ ' 9 , FAIL ,Missing order',
+ ] );
+ } );
+
+ it( 'keeps the physical row when blank rows sit between data', () => {
+ const csv = buildFailedRowsCsv(
+ 'order,tracking\n1,T-1\n\n9,T-2\n2,T-3\n',
+ ',',
+ [ failedRow( 4 ) ]
+ );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order,tracking,Import error',
+ '9,T-2,"Order not found for order number ""9""."',
+ ] );
+ } );
+
+ it( 'numbers failed rows by physical record, including leading and in-file blanks', () => {
+ const file = '\n,,,\norder,tracking\n1,T-1\n\n \n9,T-2\n2,T-3\n';
+ const csv = buildFailedRowsCsv( file, ',', [
+ failedRow( 7, 'First failure' ),
+ failedRow( 8, 'Second failure' ),
+ ] );
+
+ expect( csv.split( '\r\n' ) ).toEqual( [
+ 'order,tracking,Import error',
+ '9,T-2,First failure',
+ '2,T-3,Second failure',
+ ] );
+ } );
+} );