Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/libs/actions/ImportSpreadsheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function setSpreadsheetData(
const numColumns = firstRow.length;

// Transpose data from row-major to column-major format
const transposedData: string[][] = firstRow.map((_, colIndex) => data.map((row) => String(row.at(colIndex) ?? '')));
const transposedData: string[][] = Array.from({length: numColumns}, (_, colIndex) => data.map((row) => String(row.at(colIndex) ?? '')));

const columnNames: Record<number, string> = {};
for (let colIndex = 0; colIndex < numColumns; colIndex++) {
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/ImportSpreadsheetTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {setSpreadsheetData} from '@libs/actions/ImportSpreadsheet';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type ImportedSpreadsheet from '@src/types/onyx/ImportedSpreadsheet';

import Onyx from 'react-native-onyx';

/**
* Builds a sparse row, mirroring what `XLSX.utils.sheet_to_json(worksheet, {header: 1})` returns for a sheet with a
* blank column: the missing cell is left as an array *hole*, not as `undefined`.
*/
function buildSparseRow(cells: string[], holeIndexes: number[]): string[] {
const row = new Array<string>(cells.length);
for (const [index, cell] of cells.entries()) {
if (holeIndexes.includes(index)) {
continue;
}
row[index] = cell;
}
return row;
}

function isImportedSpreadsheet(value: unknown): value is ImportedSpreadsheet {
return typeof value === 'object' && !!value && 'data' in value;
}

/** Replays the column-major to row-major transpose the company cards import runs when Import is pressed. */
function transposeBackToRows(columns: string[][]): string[][] {
const rows: string[][] = [];
for (let rowIndex = 0; rowIndex < (columns.at(0)?.length ?? 0); rowIndex++) {
const row: string[] = [];
for (const column of columns) {
row.push(column.at(rowIndex) ?? '');
}
rows.push(row);
}
return rows;
}

describe('ImportSpreadsheet', () => {
describe('setSpreadsheetData', () => {
let storedSpreadsheet: ImportedSpreadsheet | undefined;

beforeEach(() => {
storedSpreadsheet = undefined;
jest.spyOn(Onyx, 'set').mockImplementation((key, value) => {
if (key === ONYXKEYS.IMPORTED_SPREADSHEET && isImportedSpreadsheet(value)) {
storedSpreadsheet = value;
}
return Promise.resolve();
});
});

afterEach(() => {
jest.restoreAllMocks();
});

async function importData(data: string[][]) {
await setSpreadsheetData(data, 'file://spreadsheet.csv', 'text/csv', 'spreadsheet.csv', false);
expect(storedSpreadsheet).toBeDefined();
return storedSpreadsheet;
}

it('transposes dense data to column-major format', async () => {
const spreadsheet = await importData([
['Date', 'Merchant', 'Amount'],
['2026-07-21', 'Starbucks', '10'],
['2026-07-22', 'Amazon', '20'],
]);

expect(spreadsheet?.data).toEqual([
['Date', '2026-07-21', '2026-07-22'],
['Merchant', 'Starbucks', 'Amazon'],
['Amount', '10', '20'],
]);
expect(Object.values(spreadsheet?.columns ?? {})).toEqual([CONST.CSV_IMPORT_COLUMNS.IGNORE, CONST.CSV_IMPORT_COLUMNS.IGNORE, CONST.CSV_IMPORT_COLUMNS.IGNORE]);
});

it('produces a dense column for a blank column in the header row', async () => {
const spreadsheet = await importData([buildSparseRow(['Date', '', 'Amount'], [1]), buildSparseRow(['2026-07-21', '', '10'], [1])]);

expect(spreadsheet?.data).toEqual([
['Date', '2026-07-21'],
['', ''],
['Amount', '10'],
]);
// Object.keys skips array holes, so this only matches the length when every index holds a real column
expect(Object.keys(spreadsheet?.data ?? []).length).toBe(spreadsheet?.data?.length);
});

it('keeps the column count in sync with the generated column roles', async () => {
const spreadsheet = await importData([buildSparseRow(['Date', '', '', 'Amount'], [1, 2]), buildSparseRow(['2026-07-21', '', '', '10'], [1, 2])]);

expect(spreadsheet?.data?.length).toBe(4);
expect(Object.keys(spreadsheet?.data ?? []).length).toBe(4);
expect(Object.keys(spreadsheet?.columns ?? {}).length).toBe(4);
});

it('lets the company cards import transpose the data back without throwing', async () => {
const spreadsheet = await importData([buildSparseRow(['Date', '', 'Amount'], [1]), buildSparseRow(['2026-07-21', '', '10'], [1]), buildSparseRow(['2026-07-22', '', '20'], [1])]);

expect(transposeBackToRows(spreadsheet?.data ?? [])).toEqual([
['Date', '', 'Amount'],
['2026-07-21', '', '10'],
['2026-07-22', '', '20'],
]);
});

it('pads ragged rows so every column has one cell per row', async () => {
const spreadsheet = await importData([
['Date', 'Merchant', 'Amount'],
['2026-07-21', 'Starbucks'],
]);

expect(spreadsheet?.data).toEqual([
['Date', '2026-07-21'],
['Merchant', 'Starbucks'],
['Amount', ''],
]);
});

it('rejects data that does not have at least a header and one row', async () => {
await expect(setSpreadsheetData([['Date', 'Amount']], 'file://spreadsheet.csv', 'text/csv', 'spreadsheet.csv', false)).rejects.toThrow(
'Invalid data format: file must contain at least 2 rows',
);
expect(storedSpreadsheet).toBeUndefined();
});
});
});
Loading