diff --git a/src/CONST.ts b/src/CONST.ts index 72a84925f594..2a500fefb9cd 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -67,7 +67,7 @@ const chatTypes = { // Explicit type annotation is required const cardActiveStates: number[] = [2, 3, 4, 7]; -// Hide not issued or not activated cards (states 2 and 4) from card filter options in search, as no transactions can be made on cards in these states +// Hide not issued or not activated cards (states 2, 4) from card filter options in search, as no transactions can be made on cards in these states const cardHiddenFromSearchStates: number[] = [2, 4]; const selectableOnboardingChoices = { diff --git a/src/languages/en.ts b/src/languages/en.ts index ac866f8edbaf..4ec6fd07d97c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4996,6 +4996,7 @@ const translations = { card: { expensify: 'Expensify', individualCards: 'Individual cards', + closedCards: 'Closed cards', cardFeeds: 'Card feeds', cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) => `All ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 82fee56b23bd..ce6efefadc5c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5045,6 +5045,7 @@ const translations = { card: { expensify: 'Expensify', individualCards: 'Tarjetas individuales', + closedCards: 'Tarjetas cerradas', cardFeeds: 'Flujos de tarjetas', cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) => `Todo ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`, diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 7a48cf6bcf90..c5dd647ae3ca 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -98,6 +98,10 @@ function isCardHiddenFromSearch(card: Card) { return !card?.nameValuePairs?.isVirtual && CONST.EXPENSIFY_CARD.HIDDEN_FROM_SEARCH_STATES.includes(card.state ?? 0); } +function isCardClosed(card: Card) { + return card?.state === CONST.EXPENSIFY_CARD.STATE.CLOSED; +} + function mergeCardListWithWorkspaceFeeds(workspaceFeeds: Record, cardList = allCards, shouldExcludeCardHiddenFromSearch = false) { const feedCards: CardList = {}; Object.values(cardList).forEach((card) => { @@ -537,6 +541,7 @@ export { getSelectedFeed, getCorrectStepForSelectedBank, getCustomOrFormattedFeedName, + isCardClosed, getFilteredCardList, hasOnlyOneCardToAssign, checkIfNewFeedConnected, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index f9c1563cbd0b..a4013e0795a5 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -13,7 +13,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {openSearchFiltersCardPage, updateAdvancedFilters} from '@libs/actions/Search'; -import {getBankName, getCardFeedIcon, isCard, isCardHiddenFromSearch} from '@libs/CardUtils'; +import {getBankName, getCardFeedIcon, isCard, isCardClosed, isCardHiddenFromSearch} from '@libs/CardUtils'; import {getDescriptionForPolicyDomainCard, getPolicy} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; @@ -43,7 +43,7 @@ function getRepeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Re return Object.keys(bankFrequency).filter((bank) => bankFrequency[bank] > 1); } -function createIndividualCardFilterItem(card: Card, personalDetailsList: PersonalDetailsList, selectedCards: string[]): CardFilterItem { +function createCardFilterItem(card: Card, personalDetailsList: PersonalDetailsList, selectedCards: string[]): CardFilterItem { const personalDetails = personalDetailsList[card?.accountID ?? CONST.DEFAULT_NUMBER_ID]; const isSelected = selectedCards.includes(card.cardID.toString()); const icon = getCardFeedIcon(card?.bank as CompanyCardFeed); @@ -66,23 +66,26 @@ function createIndividualCardFilterItem(card: Card, personalDetailsList: Persona }; } -function buildIndividualCardsData( +function buildCardsData( workspaceCardFeeds: Record, userCardList: CardList, personalDetailsList: PersonalDetailsList, selectedCards: string[], + isClosedCards = false, ): ItemsGroupedBySelection { + // Filter condition to build different cards data for closed cards and individual cards based on the isClosedCards flag, we don't want to show closed cards in the individual cards section + const filterCondition = (card: Card) => (isClosedCards ? isCardClosed(card) : !isCardHiddenFromSearch(card) && !isCardClosed(card)); const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}) - .filter((card) => !isCardHiddenFromSearch(card)) - .map((card) => createIndividualCardFilterItem(card, personalDetailsList, selectedCards)); + .filter((card) => filterCondition(card)) + .map((card) => createCardFilterItem(card, personalDetailsList, selectedCards)); // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key const allWorkspaceCards: CardFilterItem[] = Object.values(workspaceCardFeeds) .filter((cardFeed) => !isEmptyObject(cardFeed)) .flatMap((cardFeed) => { return Object.values(cardFeed as Record) - .filter((card) => card && isCard(card) && !userCardList?.[card.cardID] && !isCardHiddenFromSearch(card)) - .map((card) => createIndividualCardFilterItem(card, personalDetailsList, selectedCards)); + .filter((card) => card && isCard(card) && !userCardList?.[card.cardID] && filterCondition(card)) + .map((card) => createCardFilterItem(card, personalDetailsList, selectedCards)); }); const allCardItems = [...userAssignedCards, ...allWorkspaceCards]; @@ -172,6 +175,7 @@ function buildCardFeedsData( const isBankRepeating = repeatingBanks.includes(bank); const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; const correspondingPolicy = getPolicy(policyID?.toUpperCase()); + // We need to assign correspondingCardIDs for closed cards as well, because we need to be able to select on "all" both closed and individual cards const correspondingCardIDs = Object.entries(cardFeed ?? {}) .filter(([cardKey, card]) => cardKey !== 'cardList' && isCard(card) && !isCardHiddenFromSearch(card)) .map(([cardKey]) => cardKey); @@ -211,7 +215,12 @@ function SearchFiltersCardPage() { }, []); const individualCardsSectionData = useMemo( - () => buildIndividualCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, personalDetails ?? {}, selectedCards), + () => buildCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, personalDetails ?? {}, selectedCards, false), + [workspaceCardFeeds, userCardList, personalDetails, selectedCards], + ); + + const closedCardsSectionData = useMemo( + () => buildCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, personalDetails ?? {}, selectedCards, true), [workspaceCardFeeds, userCardList, personalDetails, selectedCards], ); @@ -251,7 +260,7 @@ function SearchFiltersCardPage() { const sections = useMemo(() => { const newSections = []; - const selectedItems = [...cardFeedsSectionData.selected, ...individualCardsSectionData.selected]; + const selectedItems = [...cardFeedsSectionData.selected, ...individualCardsSectionData.selected, ...closedCardsSectionData.selected]; newSections.push({ title: undefined, @@ -268,8 +277,22 @@ function SearchFiltersCardPage() { data: individualCardsSectionData.unselected.filter(searchFunction), shouldShow: individualCardsSectionData.unselected.length > 0, }); + newSections.push({ + title: translate('search.filters.card.closedCards'), + data: closedCardsSectionData.unselected.filter(searchFunction), + shouldShow: closedCardsSectionData.unselected.length > 0, + }); return newSections; - }, [cardFeedsSectionData.selected, cardFeedsSectionData.unselected, individualCardsSectionData.selected, individualCardsSectionData.unselected, searchFunction, translate]); + }, [ + cardFeedsSectionData.selected, + cardFeedsSectionData.unselected, + individualCardsSectionData.selected, + individualCardsSectionData.unselected, + closedCardsSectionData.selected, + closedCardsSectionData.unselected, + searchFunction, + translate, + ]); const handleConfirmSelection = useCallback(() => { updateAdvancedFilters({ @@ -354,4 +377,4 @@ function SearchFiltersCardPage() { SearchFiltersCardPage.displayName = 'SearchFiltersCardPage'; export default SearchFiltersCardPage; -export {buildIndividualCardsData, buildCardFeedsData}; +export {buildCardsData, buildCardFeedsData}; diff --git a/tests/unit/Search/buildCardFilterDataTest.ts b/tests/unit/Search/buildCardFilterDataTest.ts index 25736889db57..23d911c5901d 100644 --- a/tests/unit/Search/buildCardFilterDataTest.ts +++ b/tests/unit/Search/buildCardFilterDataTest.ts @@ -4,7 +4,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; // eslint-disable-next-line no-restricted-syntax import * as PolicyUtils from '@libs/PolicyUtils'; -import {buildCardFeedsData, buildIndividualCardsData} from '@pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage'; +import {buildCardFeedsData, buildCardsData} from '@pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage'; import type {CardList, Policy, WorkspaceCardsList} from '@src/types/onyx'; // Use jest.spyOn to mock the implementation @@ -204,13 +204,81 @@ const cardListHiddenOnSearch = { }, }; +const workspaceCardFeedsClosed = { + 'cards_11111_Expensify Card': { + '21534278': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21534278, + domainName: 'expensify-policy1.exfy', + nameValuePairs: {cardTitle: 'Not Issued card'}, + isVirtual: false, + lastFourPAN: '1234', + state: 6, // CLOSED + }, + '21539012': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21539012, + domainName: 'expensify-policy1.exfy', + nameValuePairs: {cardTitle: 'Not activated card'}, + isVirtual: false, + lastFourPAN: '3211', + state: 6, // CLOSED + }, + '21539027': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21539027, + domainName: 'expensify-policy1.exfy', + nameValuePairs: {cardTitle: 'Not activated card'}, + isVirtual: false, + lastFourPAN: '', + state: 3, // OPEN + }, + }, +}; + +const cardListClosed = { + '21534538': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21534538, + domainName: 'expensify-policy1.exfy', + nameValuePairs: {cardTitle: 'Not Issued card'}, + isVirtual: false, + lastFourPAN: '', + state: 6, // CLOSED + }, + '21534525': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21534525, + domainName: 'expensify-policy1.exfy', + nameValuePairs: {cardTitle: 'Not activated card'}, + isVirtual: false, + lastFourPAN: '', + state: 6, // CLOSED + }, + '21534526': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21534526, + domainName: 'expensify-policy1.exfy', + nameValuePairs: {cardTitle: 'Not activated card'}, + isVirtual: false, + lastFourPAN: '', + state: 3, // OPEN + }, +}; + const domainFeedDataMock = {testDomain: {domainName: 'testDomain', bank: 'Expensify Card', correspondingCardIDs: ['11111111']}}; const translateMock = jest.fn(); -describe('buildIndividualCardsData', () => { +describe('buildCardsData individual cards', () => { it("Builds all individual cards and doesn't generate duplicates", () => { - const result = buildIndividualCardsData(workspaceCardFeeds as unknown as Record, cardList as unknown as CardList, {}, ['21588678']); + const result = buildCardsData(workspaceCardFeeds as unknown as Record, cardList as unknown as CardList, {}, ['21588678']); expect(result.unselected.length + result.selected.length).toEqual(11); @@ -229,19 +297,35 @@ describe('buildIndividualCardsData', () => { }); }); it("Doesn't include physical cards that haven't been issued or haven't been activated", () => { - const result = buildIndividualCardsData( - workspaceCardFeedsHiddenOnSearch as unknown as Record, - cardListHiddenOnSearch as unknown as CardList, - {}, - [], - ); + const result = buildCardsData(workspaceCardFeedsHiddenOnSearch as unknown as Record, cardListHiddenOnSearch as unknown as CardList, {}, []); expect(result.unselected.length + result.selected.length).toEqual(0); }); }); -describe('buildIndividualCardsData with empty argument objects', () => { +describe('buildCardsData closed cards', () => { + it("Builds all closed cards and doesn't generate duplicates", () => { + const result = buildCardsData(workspaceCardFeedsClosed as unknown as Record, cardListClosed as unknown as CardList, {}, ['21539012'], true); + expect(result.unselected.length + result.selected.length).toEqual(4); + + // Check if Expensify card was built correctly + const expensifyCard = result.selected.find((card) => card.keyForList === '21539012'); + expect(expensifyCard).toMatchObject({ + lastFourPAN: '3211', + isSelected: true, + }); + + // Check if company card was built correctly + const companyCard = result.unselected.find((card) => card.keyForList === '21534525'); + expect(companyCard).toMatchObject({ + lastFourPAN: '', + isSelected: false, + }); + }); +}); + +describe('buildCardsData with empty argument objects', () => { it('Returns empty array when cardList and workspaceCardFeeds are empty', () => { - const result = buildIndividualCardsData({}, {}, {}, []); + const result = buildCardsData({}, {}, {}, []); expect(result).toEqual({selected: [], unselected: []}); }); });