From ea5a04fd491b89fec9c65722ee14cd9b9a691a9a Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 2 Jul 2026 12:14:56 +0200 Subject: [PATCH 1/7] Use stored mappings for columns --- src/libs/actions/ImportSpreadsheet.ts | 37 ++++++++ .../addNew/CompanyCardsImportedPage.tsx | 34 ++++++- tests/unit/ImportTransactions.test.ts | 94 ++++++++++++++++++- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/ImportSpreadsheet.ts b/src/libs/actions/ImportSpreadsheet.ts index c15c3fbac70e..80f92ab12550 100644 --- a/src/libs/actions/ImportSpreadsheet.ts +++ b/src/libs/actions/ImportSpreadsheet.ts @@ -200,6 +200,42 @@ function applySavedColumnMappings(spreadsheetData: string[][], savedLayout: Save } } +/** + * Applies saved company card column mappings to the spreadsheet data. + * Company card layouts (stored in the shared domain NVP) map a field role to a column index, + * so mappings are re-applied by index. Roles that are not selectable in the current context + * (e.g. advanced fields when advanced fields are disabled) are skipped so the header heuristics + * can still fill those columns. + * + * @param spreadsheetData - The spreadsheet data in column-major format + * @param savedColumnMappings - Saved mappings from uploadLayoutSettings, keyed by field role with a column index value + * @param availableColumnRoles - The field roles currently selectable in the mapping UI + */ +function applyCompanyCardSavedColumnMappings(spreadsheetData: string[][], savedColumnMappings: Record, availableColumnRoles: string[]): void { + if (!savedColumnMappings) { + return; + } + + const validRoles = new Set(availableColumnRoles); + const numColumns = spreadsheetData.length; + const columnUpdates: Record = {}; + + for (const [role, indexValue] of Object.entries(savedColumnMappings)) { + if (role === CONST.CSV_IMPORT_COLUMNS.IGNORE || !validRoles.has(role)) { + continue; + } + const index = Number(indexValue); + if (!Number.isInteger(index) || index < 0 || index >= numColumns) { + continue; + } + columnUpdates[index] = role; + } + + if (Object.keys(columnUpdates).length > 0) { + Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {columns: columnUpdates}); + } +} + export { setSpreadsheetData, setColumnName, @@ -209,6 +245,7 @@ export { setImportTransactionCurrency, setImportTransactionSettings, applySavedColumnMappings, + applyCompanyCardSavedColumnMappings, getImportFailedFinalModal, getImportFinalModalID, getImportFinalModalOnyxData, diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index 6ebd47e10c25..aac4a4a8b0eb 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {ColumnRole} from '@components/ImportColumn'; import ImportSpreadsheetColumns from '@components/ImportSpreadsheetColumns'; @@ -8,6 +8,7 @@ import useImportSpreadsheetConfirmModal from '@hooks/useImportSpreadsheetConfirm import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import {applyCompanyCardSavedColumnMappings} from '@libs/actions/ImportSpreadsheet'; import {getCSVFeedType} from '@libs/CardUtils'; import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -50,7 +51,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); - const columnRoles: ColumnRole[] = (() => { + const columnRoles: ColumnRole[] = useMemo(() => { const baseRoles: ColumnRole[] = [ {text: translate('workspace.companyCards.addNewCard.csvColumns.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, {text: translate('workspace.companyCards.addNewCard.csvColumns.cardNumber'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER, isRequired: true}, @@ -74,7 +75,34 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { ]; return [...baseRoles, ...advancedRoles]; - })(); + }, [translate, shouldUseAdvancedFields]); + + const savedColumnMappings = workspaceCardFeeds?.settings?.companyCards?.[layoutType]?.uploadLayoutSettings?.columnMappings; + const hasAppliedSavedMappings = useRef(false); + const lastProcessedDataRef = useRef(spreadsheet?.data); + + useEffect(() => { + // Reset the flag when new spreadsheet data is loaded + if (spreadsheet?.data !== lastProcessedDataRef.current) { + hasAppliedSavedMappings.current = false; + lastProcessedDataRef.current = spreadsheet?.data; + } + + if (hasAppliedSavedMappings.current) { + return; + } + + if (!spreadsheet?.data || isEmptyObject(savedColumnMappings)) { + return; + } + + hasAppliedSavedMappings.current = true; + applyCompanyCardSavedColumnMappings( + spreadsheet.data, + savedColumnMappings, + columnRoles.map((role) => role.value), + ); + }, [spreadsheet?.data, savedColumnMappings, columnRoles]); const requiredColumns = columnRoles.filter((role) => role.isRequired); diff --git a/tests/unit/ImportTransactions.test.ts b/tests/unit/ImportTransactions.test.ts index a61342b84a00..04564d784d8d 100644 --- a/tests/unit/ImportTransactions.test.ts +++ b/tests/unit/ImportTransactions.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import Onyx from 'react-native-onyx'; -import {applySavedColumnMappings, getImportFinalModalOnyxData} from '@libs/actions/ImportSpreadsheet'; +import {applyCompanyCardSavedColumnMappings, applySavedColumnMappings, getImportFinalModalOnyxData} from '@libs/actions/ImportSpreadsheet'; import importTransactionsFromCSV, {buildColumnLayout, buildTransactionListFromSpreadsheet, getColumnIndexes} from '@libs/actions/ImportTransactions'; import * as API from '@libs/API'; import CONST from '@src/CONST'; @@ -774,6 +774,98 @@ describe('ImportTransactions', () => { }); }); + describe('applyCompanyCardSavedColumnMappings', () => { + const availableRoles = [ + CONST.CSV_IMPORT_COLUMNS.IGNORE, + CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER, + CONST.CSV_IMPORT_COLUMNS.POSTED_DATE, + CONST.CSV_IMPORT_COLUMNS.MERCHANT, + CONST.CSV_IMPORT_COLUMNS.AMOUNT, + CONST.CSV_IMPORT_COLUMNS.CURRENCY, + ]; + + // Column-major spreadsheet data (each entry is a column) + const spreadsheetData = [ + ['Card', '4111', '4111'], + ['Posted', '2024-01-01', '2024-01-02'], + ['Merchant', 'Store A', 'Store B'], + ['Amount', '10.00', '20.00'], + ['Currency', 'USD', 'USD'], + ]; + + it('should apply saved company card column mappings by index', () => { + const savedColumnMappings = { + cardNumber: '0', + postedDate: '1', + merchant: '2', + amount: '3', + currency: '4', + }; + + applyCompanyCardSavedColumnMappings(spreadsheetData, savedColumnMappings, availableRoles); + + expect(Onyx.merge).toHaveBeenCalledWith(ONYXKEYS.IMPORTED_SPREADSHEET, { + columns: { + 0: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER, + 1: CONST.CSV_IMPORT_COLUMNS.POSTED_DATE, + 2: CONST.CSV_IMPORT_COLUMNS.MERCHANT, + 3: CONST.CSV_IMPORT_COLUMNS.AMOUNT, + 4: CONST.CSV_IMPORT_COLUMNS.CURRENCY, + }, + }); + }); + + it('should skip roles that are not selectable in the current context', () => { + const savedColumnMappings = { + cardNumber: '0', + merchant: '2', + amount: '3', + // Advanced field not available when advanced fields are disabled + originalAmount: '1', + // Auto-generated column that is never a selectable role + externalID: '4', + }; + + applyCompanyCardSavedColumnMappings(spreadsheetData, savedColumnMappings, availableRoles); + + expect(Onyx.merge).toHaveBeenCalledWith(ONYXKEYS.IMPORTED_SPREADSHEET, { + columns: { + 0: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER, + 2: CONST.CSV_IMPORT_COLUMNS.MERCHANT, + 3: CONST.CSV_IMPORT_COLUMNS.AMOUNT, + }, + }); + }); + + it('should skip the ignore role and out-of-range indexes', () => { + const savedColumnMappings = { + ignore: '0', + merchant: '2', + amount: '99', + currency: '-1', + }; + + applyCompanyCardSavedColumnMappings(spreadsheetData, savedColumnMappings, availableRoles); + + expect(Onyx.merge).toHaveBeenCalledWith(ONYXKEYS.IMPORTED_SPREADSHEET, { + columns: { + 2: CONST.CSV_IMPORT_COLUMNS.MERCHANT, + }, + }); + }); + + it('should not call Onyx.merge when no columns can be mapped', () => { + const savedColumnMappings = { + originalAmount: '1', + originalCurrency: '4', + }; + + applyCompanyCardSavedColumnMappings(spreadsheetData, savedColumnMappings, availableRoles); + + expect(Onyx.merge).not.toHaveBeenCalled(); + }); + }); + describe('importTransactionsFromCSV', () => { const CURRENT_USER_ACCOUNT_ID = 12345; const validSpreadsheet = { From dcf1797ae2cdc1d92dd9a94de1114362a7f4beaf Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 2 Jul 2026 13:53:25 +0200 Subject: [PATCH 2/7] simplify comments --- src/libs/actions/ImportSpreadsheet.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/libs/actions/ImportSpreadsheet.ts b/src/libs/actions/ImportSpreadsheet.ts index 80f92ab12550..664530e4d2da 100644 --- a/src/libs/actions/ImportSpreadsheet.ts +++ b/src/libs/actions/ImportSpreadsheet.ts @@ -202,10 +202,6 @@ function applySavedColumnMappings(spreadsheetData: string[][], savedLayout: Save /** * Applies saved company card column mappings to the spreadsheet data. - * Company card layouts (stored in the shared domain NVP) map a field role to a column index, - * so mappings are re-applied by index. Roles that are not selectable in the current context - * (e.g. advanced fields when advanced fields are disabled) are skipped so the header heuristics - * can still fill those columns. * * @param spreadsheetData - The spreadsheet data in column-major format * @param savedColumnMappings - Saved mappings from uploadLayoutSettings, keyed by field role with a column index value From b9ad821bdb9b30c305b1c0a384e03b863e352782 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 2 Jul 2026 14:55:47 +0200 Subject: [PATCH 3/7] types --- .../workspace/companyCards/addNew/CompanyCardsImportedPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index aac4a4a8b0eb..3470f2506b72 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -22,6 +22,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -77,7 +78,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { return [...baseRoles, ...advancedRoles]; }, [translate, shouldUseAdvancedFields]); - const savedColumnMappings = workspaceCardFeeds?.settings?.companyCards?.[layoutType]?.uploadLayoutSettings?.columnMappings; + const savedColumnMappings = workspaceCardFeeds?.settings?.companyCards?.[layoutType as CardFeedWithNumber]?.uploadLayoutSettings?.columnMappings; const hasAppliedSavedMappings = useRef(false); const lastProcessedDataRef = useRef(spreadsheet?.data); From 5f87579b7574e54e2baefc4edaabf0d9cfb1ffa6 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 2 Jul 2026 15:01:50 +0200 Subject: [PATCH 4/7] lint --- .../workspace/companyCards/addNew/CompanyCardsImportedPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index 3470f2506b72..8cf20fcd778c 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -22,7 +22,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -78,7 +77,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { return [...baseRoles, ...advancedRoles]; }, [translate, shouldUseAdvancedFields]); - const savedColumnMappings = workspaceCardFeeds?.settings?.companyCards?.[layoutType as CardFeedWithNumber]?.uploadLayoutSettings?.columnMappings; + const savedColumnMappings = Object.entries(workspaceCardFeeds?.settings?.companyCards ?? {}).find(([feedKey]) => feedKey === layoutType)?.[1]?.uploadLayoutSettings?.columnMappings; const hasAppliedSavedMappings = useRef(false); const lastProcessedDataRef = useRef(spreadsheet?.data); From d3b3b8f915c51447c114d9ffeeabd7a00d96a5ac Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 2 Jul 2026 15:39:16 +0200 Subject: [PATCH 5/7] unmemo --- .../companyCards/addNew/CompanyCardsImportedPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index 8cf20fcd778c..5debf8a95162 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -51,7 +51,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); - const columnRoles: ColumnRole[] = useMemo(() => { + const columnRoles: ColumnRole[] = (() => { const baseRoles: ColumnRole[] = [ {text: translate('workspace.companyCards.addNewCard.csvColumns.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, {text: translate('workspace.companyCards.addNewCard.csvColumns.cardNumber'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER, isRequired: true}, @@ -75,7 +75,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { ]; return [...baseRoles, ...advancedRoles]; - }, [translate, shouldUseAdvancedFields]); + })(); const savedColumnMappings = Object.entries(workspaceCardFeeds?.settings?.companyCards ?? {}).find(([feedKey]) => feedKey === layoutType)?.[1]?.uploadLayoutSettings?.columnMappings; const hasAppliedSavedMappings = useRef(false); From 8a67ab7f8cbca66c3f294921d9819a1d84fca7c0 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 2 Jul 2026 15:43:35 +0200 Subject: [PATCH 6/7] race condition fix --- .../companyCards/addNew/CompanyCardsImportedPage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index 5debf8a95162..c7e6f11acf34 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -80,12 +80,14 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const savedColumnMappings = Object.entries(workspaceCardFeeds?.settings?.companyCards ?? {}).find(([feedKey]) => feedKey === layoutType)?.[1]?.uploadLayoutSettings?.columnMappings; const hasAppliedSavedMappings = useRef(false); const lastProcessedDataRef = useRef(spreadsheet?.data); + const lastAdvancedFieldsRef = useRef(shouldUseAdvancedFields); useEffect(() => { - // Reset the flag when new spreadsheet data is loaded - if (spreadsheet?.data !== lastProcessedDataRef.current) { + // Reset the flag when new spreadsheet data is loaded, or when the set of selectable roles changes. + if (spreadsheet?.data !== lastProcessedDataRef.current || shouldUseAdvancedFields !== lastAdvancedFieldsRef.current) { hasAppliedSavedMappings.current = false; lastProcessedDataRef.current = spreadsheet?.data; + lastAdvancedFieldsRef.current = shouldUseAdvancedFields; } if (hasAppliedSavedMappings.current) { @@ -102,7 +104,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { savedColumnMappings, columnRoles.map((role) => role.value), ); - }, [spreadsheet?.data, savedColumnMappings, columnRoles]); + }, [spreadsheet?.data, savedColumnMappings, columnRoles, shouldUseAdvancedFields]); const requiredColumns = columnRoles.filter((role) => role.isRequired); From 9f43b38fb059e7e7de2fc28ca57a810509891281 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 7 Jul 2026 16:19:15 +0200 Subject: [PATCH 7/7] style --- .../companyCards/addNew/CompanyCardsImportedPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index f9d8ede36f99..1a6366485fd2 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -1,5 +1,3 @@ - -import React, {useEffect, useMemo, useRef, useState} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {ColumnRole} from '@components/ImportColumn'; import ImportSpreadsheetColumns from '@components/ImportSpreadsheetColumns'; @@ -10,6 +8,7 @@ import useImportSpreadsheetConfirmModal from '@hooks/useImportSpreadsheetConfirm import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; + import {applyCompanyCardSavedColumnMappings} from '@libs/actions/ImportSpreadsheet'; import {getCSVFeedType} from '@libs/CardUtils'; import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; @@ -32,6 +31,8 @@ import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; + type CompanyCardsImportedPageProps = PlatformStackScreenProps; function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) {