From 3dc33a41c8493da2c310c88cdaaa849c99812e33 Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 10 Apr 2026 12:20:15 +0200 Subject: [PATCH 1/3] Use new helper function to check existing CSV feeds --- src/libs/CardUtils.ts | 74 +++++++++++++++++++ .../addNew/CompanyCardsImportedPage.tsx | 14 ++-- tests/unit/CardUtilsTest.ts | 40 +++++++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 1aef4d27fbbb..59539324d8fd 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1054,6 +1054,79 @@ function isSmartLimitEnabled(cardsList: CardList) { const CUSTOM_FEEDS = [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, CONST.COMPANY_CARD.FEED_BANK_NAME.CSV]; +function collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards: CompanyFeeds | undefined, csvPrefix: string): number[] { + const numbers: number[] = []; + for (const key of Object.keys(companyCards ?? {})) { + if (!key.startsWith(csvPrefix)) { + continue; + } + const suffix = key.slice(csvPrefix.length); + if (!suffix) { + continue; + } + const n = Number.parseInt(suffix, 10); + if (!Number.isNaN(n) && n > 0) { + numbers.push(n); + } + } + return numbers; +} + +function collectUsedCSVFeedSlotNumbersFromWorkspaceCardsCollection( + allWorkspaceCardsList: OnyxCollection | undefined, + domainID: number, + csvPrefix: string, +): number[] { + const numbers: number[] = []; + const domainKeyPrefix = `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainID}_`; + for (const key of Object.keys(allWorkspaceCardsList ?? {})) { + if (!key.startsWith(domainKeyPrefix)) { + continue; + } + const feedPart = key.slice(domainKeyPrefix.length); + if (!feedPart.startsWith(csvPrefix)) { + continue; + } + const suffix = feedPart.slice(csvPrefix.length); + if (!suffix) { + continue; + } + const n = Number.parseInt(suffix, 10); + if (!Number.isNaN(n) && n > 0) { + numbers.push(n); + } + } + return numbers; +} + +/** + * Next available CSV file-import feed (`ccuploadN`). Uses `settings.companyCards` and any + * `cards__ccuploadN` workspace card keys so numbering matches Onyx, including feeds + * omitted from {@link getFeedType}'s `CombinedCardFeeds` input. + */ +function getCSVFeedType( + companyCards: CompanyFeeds | undefined, + domainID: number, + allWorkspaceCardsList: OnyxCollection | undefined, +): CompanyCardFeedWithNumber { + const csvPrefix = CONST.COMPANY_CARD.FEED_BANK_NAME.CSV; + const merged = [ + ...collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards, csvPrefix), + ...collectUsedCSVFeedSlotNumbersFromWorkspaceCardsCollection(allWorkspaceCardsList, domainID, csvPrefix), + ]; + const feedNumbers = [...new Set(merged)].sort((a, b) => a - b); + + let firstAvailableNumber = 1; + for (const num of feedNumbers) { + if (num && num !== firstAvailableNumber) { + return `${csvPrefix}${firstAvailableNumber}`; + } + firstAvailableNumber++; + } + + return `${csvPrefix}${firstAvailableNumber}`; +} + function getFeedType(feedKey: CompanyCardFeed, cardFeeds: OnyxEntry): CompanyCardFeedWithNumber { if (CUSTOM_FEEDS.some((feed) => feed === feedKey)) { const filteredFeeds = Object.keys(cardFeeds ?? {}) @@ -1670,6 +1743,7 @@ export { filterCardsByNonExpensify, getAllCardsForWorkspace, isCardHiddenFromSearch, + getCSVFeedType, getFeedType, flattenWorkspaceCardsList, isCardConnectionBroken, diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index ff8513cedcb7..a787bda1942f 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -1,15 +1,14 @@ -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {ColumnRole} from '@components/ImportColumn'; import ImportSpreadsheetColumns from '@components/ImportSpreadsheetColumns'; import ImportSpreadsheetConfirmModal from '@components/ImportSpreadsheetConfirmModal'; import ScreenWrapper from '@components/ScreenWrapper'; -import useCardFeeds from '@hooks/useCardFeeds'; import useCloseImportPage from '@hooks/useCloseImportPage'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; -import {getFeedType} from '@libs/CardUtils'; +import {getCSVFeedType} from '@libs/CardUtils'; import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -37,13 +36,18 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); - const [cardFeeds] = useCardFeeds(policyID); + const [allWorkspaceCardsList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [isImportingTransactions, setIsImportingTransactions] = useState(false); const {setIsClosing} = useCloseImportPage(); const shouldUseAdvancedFields = addNewCard?.data?.useAdvancedFields ?? false; const layoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const prefilledLayoutType = addNewCard?.data?.layoutType; - const [generatedLayoutType] = useState(() => prefilledLayoutType ?? getFeedType(CONST.COMPANY_CARD.FEED_BANK_NAME.CSV, cardFeeds)); + const generatedLayoutType = useMemo( + () => + prefilledLayoutType ?? + getCSVFeedType(workspaceCardFeeds?.settings?.companyCards, workspaceAccountID, allWorkspaceCardsList), + [prefilledLayoutType, workspaceCardFeeds?.settings?.companyCards, workspaceAccountID, allWorkspaceCardsList], + ); const layoutType = prefilledLayoutType ?? generatedLayoutType; const [existingCardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${layoutType}`); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index cddf074db1ec..d6cf77c329c4 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -4,6 +4,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import type {FeedKeysWithAssignedCards} from '@hooks/useFeedKeysWithAssignedCards'; import type IllustrationsType from '@styles/theme/illustrations/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {CombinedCardFeeds} from '@src/hooks/useCardFeeds'; import IntlStore from '@src/languages/IntlStore'; import { @@ -37,6 +38,7 @@ import { getEligibleBankAccountsForUkEuCard, getFeedConnectionBrokenCard, getFeedNameForDisplay, + getCSVFeedType, getFeedType, getFilteredCardList, getMonthFromExpirationDateString, @@ -78,7 +80,7 @@ import type { Policy, WorkspaceCardsList, } from '@src/types/onyx'; -import type {CardFeedWithDomainID, CardFeedWithNumber, CompanyCardFeedWithNumber} from '@src/types/onyx/CardFeeds'; +import type {CardFeedWithDomainID, CardFeedWithNumber, CompanyFeeds, CompanyCardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type IconAsset from '@src/types/utils/IconAsset'; import {localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -1882,6 +1884,42 @@ describe('CardUtils', () => { }); }); + describe('getCSVFeedType', () => { + it('returns the first gap when higher-numbered CSV feeds exist in companyCards only', () => { + expect( + getCSVFeedType( + { + ccupload3: {pending: false}, + ccupload7: {pending: false}, + } as CompanyFeeds, + 111, + undefined, + ), + ).toBe('ccupload1'); + }); + + it('treats existing workspace card list keys as used slots', () => { + const collection = { + [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}111_ccupload3`]: {cardList: {}}, + } as OnyxCollection; + expect(getCSVFeedType(undefined, 111, collection)).toBe('ccupload1'); + }); + + it('merges companyCards and workspace keys when picking the next slot', () => { + expect( + getCSVFeedType( + {ccupload1: {pending: false}} as CompanyFeeds, + 111, + {[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}111_ccupload2`]: {cardList: {}}} as OnyxCollection, + ), + ).toBe('ccupload3'); + }); + + it('returns ccupload1 when no CSV feeds exist', () => { + expect(getCSVFeedType(undefined, 111, {})).toBe('ccupload1'); + }); + }); + describe('flattenCompanyCards', () => { it('should return the flattened list of non-Expensify cards related to the provided workspaceAccountID', () => { const workspaceAccountID = 11111111; From 7b095711ebdf31435147441bfb6956e16d7d4edd Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 10 Apr 2026 13:06:21 +0200 Subject: [PATCH 2/3] prettier --- src/libs/CardUtils.ts | 12 ++---------- .../addNew/CompanyCardsImportedPage.tsx | 4 +--- tests/unit/CardUtilsTest.ts | 14 ++++++-------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 59539324d8fd..433edcf2a203 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1072,11 +1072,7 @@ function collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards: CompanyFeed return numbers; } -function collectUsedCSVFeedSlotNumbersFromWorkspaceCardsCollection( - allWorkspaceCardsList: OnyxCollection | undefined, - domainID: number, - csvPrefix: string, -): number[] { +function collectUsedCSVFeedSlotNumbersFromWorkspaceCardsCollection(allWorkspaceCardsList: OnyxCollection | undefined, domainID: number, csvPrefix: string): number[] { const numbers: number[] = []; const domainKeyPrefix = `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainID}_`; for (const key of Object.keys(allWorkspaceCardsList ?? {})) { @@ -1104,11 +1100,7 @@ function collectUsedCSVFeedSlotNumbersFromWorkspaceCardsCollection( * `cards__ccuploadN` workspace card keys so numbering matches Onyx, including feeds * omitted from {@link getFeedType}'s `CombinedCardFeeds` input. */ -function getCSVFeedType( - companyCards: CompanyFeeds | undefined, - domainID: number, - allWorkspaceCardsList: OnyxCollection | undefined, -): CompanyCardFeedWithNumber { +function getCSVFeedType(companyCards: CompanyFeeds | undefined, domainID: number, allWorkspaceCardsList: OnyxCollection | undefined): CompanyCardFeedWithNumber { const csvPrefix = CONST.COMPANY_CARD.FEED_BANK_NAME.CSV; const merged = [ ...collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards, csvPrefix), diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index a787bda1942f..4b2379a15efb 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -43,9 +43,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const layoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const prefilledLayoutType = addNewCard?.data?.layoutType; const generatedLayoutType = useMemo( - () => - prefilledLayoutType ?? - getCSVFeedType(workspaceCardFeeds?.settings?.companyCards, workspaceAccountID, allWorkspaceCardsList), + () => prefilledLayoutType ?? getCSVFeedType(workspaceCardFeeds?.settings?.companyCards, workspaceAccountID, allWorkspaceCardsList), [prefilledLayoutType, workspaceCardFeeds?.settings?.companyCards, workspaceAccountID, allWorkspaceCardsList], ); const layoutType = prefilledLayoutType ?? generatedLayoutType; diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index d6cf77c329c4..af5ba81e912a 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -4,7 +4,6 @@ import type {OnyxCollection} from 'react-native-onyx'; import type {FeedKeysWithAssignedCards} from '@hooks/useFeedKeysWithAssignedCards'; import type IllustrationsType from '@styles/theme/illustrations/types'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {CombinedCardFeeds} from '@src/hooks/useCardFeeds'; import IntlStore from '@src/languages/IntlStore'; import { @@ -30,6 +29,7 @@ import { getCompanyCardDescription, getCompanyCardFeed, getCompanyFeeds, + getCSVFeedType, getCustomFeedNameFromFeeds, getCustomOrFormattedFeedName, getDefaultExpensifyCardLimitType, @@ -38,7 +38,6 @@ import { getEligibleBankAccountsForUkEuCard, getFeedConnectionBrokenCard, getFeedNameForDisplay, - getCSVFeedType, getFeedType, getFilteredCardList, getMonthFromExpirationDateString, @@ -68,6 +67,7 @@ import { } from '@src/libs/CardUtils'; import type {CardProgramKey} from '@src/libs/CardUtils'; import DateUtils from '@src/libs/DateUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type { BankAccountList, Card, @@ -80,7 +80,7 @@ import type { Policy, WorkspaceCardsList, } from '@src/types/onyx'; -import type {CardFeedWithDomainID, CardFeedWithNumber, CompanyFeeds, CompanyCardFeedWithNumber} from '@src/types/onyx/CardFeeds'; +import type {CardFeedWithDomainID, CardFeedWithNumber, CompanyCardFeedWithNumber, CompanyFeeds} from '@src/types/onyx/CardFeeds'; import type IconAsset from '@src/types/utils/IconAsset'; import {localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -1907,11 +1907,9 @@ describe('CardUtils', () => { it('merges companyCards and workspace keys when picking the next slot', () => { expect( - getCSVFeedType( - {ccupload1: {pending: false}} as CompanyFeeds, - 111, - {[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}111_ccupload2`]: {cardList: {}}} as OnyxCollection, - ), + getCSVFeedType({ccupload1: {pending: false}} as CompanyFeeds, 111, { + [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}111_ccupload2`]: {cardList: {}}, + } as OnyxCollection), ).toBe('ccupload3'); }); From 345f8c635c14311453afcb2e15bc34dc4172c555 Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 10 Apr 2026 16:17:15 +0200 Subject: [PATCH 3/3] simplify --- src/libs/CardUtils.ts | 36 +++---------------- .../addNew/CompanyCardsImportedPage.tsx | 5 ++- tests/unit/CardUtilsTest.ts | 30 ++++------------ 3 files changed, 13 insertions(+), 58 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 433edcf2a203..beaf127a23cb 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1072,41 +1072,13 @@ function collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards: CompanyFeed return numbers; } -function collectUsedCSVFeedSlotNumbersFromWorkspaceCardsCollection(allWorkspaceCardsList: OnyxCollection | undefined, domainID: number, csvPrefix: string): number[] { - const numbers: number[] = []; - const domainKeyPrefix = `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainID}_`; - for (const key of Object.keys(allWorkspaceCardsList ?? {})) { - if (!key.startsWith(domainKeyPrefix)) { - continue; - } - const feedPart = key.slice(domainKeyPrefix.length); - if (!feedPart.startsWith(csvPrefix)) { - continue; - } - const suffix = feedPart.slice(csvPrefix.length); - if (!suffix) { - continue; - } - const n = Number.parseInt(suffix, 10); - if (!Number.isNaN(n) && n > 0) { - numbers.push(n); - } - } - return numbers; -} - /** - * Next available CSV file-import feed (`ccuploadN`). Uses `settings.companyCards` and any - * `cards__ccuploadN` workspace card keys so numbering matches Onyx, including feeds - * omitted from {@link getFeedType}'s `CombinedCardFeeds` input. + * Next available CSV file-import feed (`ccuploadN`) from `settings.companyCards` keys only, + * including feeds omitted from {@link getFeedType}'s `CombinedCardFeeds` input. */ -function getCSVFeedType(companyCards: CompanyFeeds | undefined, domainID: number, allWorkspaceCardsList: OnyxCollection | undefined): CompanyCardFeedWithNumber { +function getCSVFeedType(companyCards: CompanyFeeds | undefined): CompanyCardFeedWithNumber { const csvPrefix = CONST.COMPANY_CARD.FEED_BANK_NAME.CSV; - const merged = [ - ...collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards, csvPrefix), - ...collectUsedCSVFeedSlotNumbersFromWorkspaceCardsCollection(allWorkspaceCardsList, domainID, csvPrefix), - ]; - const feedNumbers = [...new Set(merged)].sort((a, b) => a - b); + const feedNumbers = [...new Set(collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards, csvPrefix))].sort((a, b) => a - b); let firstAvailableNumber = 1; for (const num of feedNumbers) { diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index 4b2379a15efb..166eaedac718 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -36,15 +36,14 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); - const [allWorkspaceCardsList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [isImportingTransactions, setIsImportingTransactions] = useState(false); const {setIsClosing} = useCloseImportPage(); const shouldUseAdvancedFields = addNewCard?.data?.useAdvancedFields ?? false; const layoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const prefilledLayoutType = addNewCard?.data?.layoutType; const generatedLayoutType = useMemo( - () => prefilledLayoutType ?? getCSVFeedType(workspaceCardFeeds?.settings?.companyCards, workspaceAccountID, allWorkspaceCardsList), - [prefilledLayoutType, workspaceCardFeeds?.settings?.companyCards, workspaceAccountID, allWorkspaceCardsList], + () => prefilledLayoutType ?? getCSVFeedType(workspaceCardFeeds?.settings?.companyCards), + [prefilledLayoutType, workspaceCardFeeds?.settings?.companyCards], ); const layoutType = prefilledLayoutType ?? generatedLayoutType; const [existingCardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${layoutType}`); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index af5ba81e912a..2367811356b2 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -67,7 +67,6 @@ import { } from '@src/libs/CardUtils'; import type {CardProgramKey} from '@src/libs/CardUtils'; import DateUtils from '@src/libs/DateUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; import type { BankAccountList, Card, @@ -1887,34 +1886,19 @@ describe('CardUtils', () => { describe('getCSVFeedType', () => { it('returns the first gap when higher-numbered CSV feeds exist in companyCards only', () => { expect( - getCSVFeedType( - { - ccupload3: {pending: false}, - ccupload7: {pending: false}, - } as CompanyFeeds, - 111, - undefined, - ), + getCSVFeedType({ + ccupload3: {pending: false}, + ccupload7: {pending: false}, + } as CompanyFeeds), ).toBe('ccupload1'); }); - it('treats existing workspace card list keys as used slots', () => { - const collection = { - [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}111_ccupload3`]: {cardList: {}}, - } as OnyxCollection; - expect(getCSVFeedType(undefined, 111, collection)).toBe('ccupload1'); - }); - - it('merges companyCards and workspace keys when picking the next slot', () => { - expect( - getCSVFeedType({ccupload1: {pending: false}} as CompanyFeeds, 111, { - [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}111_ccupload2`]: {cardList: {}}, - } as OnyxCollection), - ).toBe('ccupload3'); + it('returns ccupload2 when ccupload1 is already in companyCards', () => { + expect(getCSVFeedType({ccupload1: {pending: false}} as CompanyFeeds)).toBe('ccupload2'); }); it('returns ccupload1 when no CSV feeds exist', () => { - expect(getCSVFeedType(undefined, 111, {})).toBe('ccupload1'); + expect(getCSVFeedType(undefined)).toBe('ccupload1'); }); });