From 3a1590b8dd96e4e0bea671ede637d4b22d235831 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Sun, 16 Feb 2025 15:07:04 +0800 Subject: [PATCH 1/8] add translations --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + 2 files changed, 2 insertions(+) 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}` : ''}`, From 2c198d80c12d4d9c1ce9911eac2153e92a0d7450 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Mon, 17 Feb 2025 14:23:40 +0800 Subject: [PATCH 2/8] Update card hidden from search states to include suspended cards --- src/CONST.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 72a84925f594..33648a3eb00e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -67,8 +67,8 @@ 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 -const cardHiddenFromSearchStates: number[] = [2, 4]; +// Hide not issued or not activated cards (states 2, 4, 6) from card filter options in search, as no transactions can be made on cards in these states +const cardHiddenFromSearchStates: number[] = [2, 4, 6]; const selectableOnboardingChoices = { PERSONAL_SPEND: 'newDotPersonalSpend', From 083c719720918a81d0e695750a173added18b064 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Mon, 17 Feb 2025 14:24:16 +0800 Subject: [PATCH 3/8] Add isCardClosed utility function to CardUtils --- src/libs/CardUtils.ts | 5 +++++ 1 file changed, 5 insertions(+) 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, From 581a04d82be80f89b000e2c4b311876c5a1efd3f Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Mon, 17 Feb 2025 16:10:02 +0800 Subject: [PATCH 4/8] Add support for closed cards in search filters --- .../SearchFiltersCardPage.tsx | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index f9c1563cbd0b..45e1dd5e6eb2 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,25 @@ function createIndividualCardFilterItem(card: Card, personalDetailsList: Persona }; } -function buildIndividualCardsData( +function buildCardsData( workspaceCardFeeds: Record, userCardList: CardList, personalDetailsList: PersonalDetailsList, selectedCards: string[], + isClosedCards = false, ): ItemsGroupedBySelection { + const filterCondition = (card: Card) => (isClosedCards ? isCardClosed(card) : !isCardHiddenFromSearch(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]; @@ -173,7 +175,7 @@ function buildCardFeedsData( const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; const correspondingPolicy = getPolicy(policyID?.toUpperCase()); const correspondingCardIDs = Object.entries(cardFeed ?? {}) - .filter(([cardKey, card]) => cardKey !== 'cardList' && isCard(card) && !isCardHiddenFromSearch(card)) + .filter(([cardKey, card]) => cardKey !== 'cardList' && isCard(card) && (!isCardHiddenFromSearch(card) || isCardClosed(card))) .map(([cardKey]) => cardKey); const feedItem = createCardFeedItem({ @@ -211,7 +213,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 +258,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 +275,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 +375,4 @@ function SearchFiltersCardPage() { SearchFiltersCardPage.displayName = 'SearchFiltersCardPage'; export default SearchFiltersCardPage; -export {buildIndividualCardsData, buildCardFeedsData}; +export {buildCardsData, buildCardFeedsData}; From 6c597876bf9b5feb345199015c4ca7f9157f44e9 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Mon, 17 Feb 2025 16:10:29 +0800 Subject: [PATCH 5/8] Update buildCardFilterDataTest to support closed card scenarios in search filters --- tests/unit/Search/buildCardFilterDataTest.ts | 106 +++++++++++++++++-- 1 file changed, 95 insertions(+), 11 deletions(-) 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: []}); }); }); From 1d29ee2bcd8d617806b4fdf0d5298c8568d5db78 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Wed, 19 Feb 2025 17:20:39 +0800 Subject: [PATCH 6/8] add comments --- .../Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 45e1dd5e6eb2..b46106c70f3f 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -73,6 +73,7 @@ function buildCardsData( selectedCards: string[], isClosedCards = false, ): ItemsGroupedBySelection { + // Filter condition to build different cards data for closed cards and individual cards based on the isClosedCards flag const filterCondition = (card: Card) => (isClosedCards ? isCardClosed(card) : !isCardHiddenFromSearch(card)); const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}) .filter((card) => filterCondition(card)) @@ -174,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) || isCardClosed(card))) .map(([cardKey]) => cardKey); From cf1229ce9dd517ab05a9f11460329ede3084ab12 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Thu, 20 Feb 2025 17:26:15 +0800 Subject: [PATCH 7/8] remove from hidden closed cards --- src/CONST.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 33648a3eb00e..2a500fefb9cd 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -67,8 +67,8 @@ const chatTypes = { // Explicit type annotation is required const cardActiveStates: number[] = [2, 3, 4, 7]; -// Hide not issued or not activated cards (states 2, 4, 6) from card filter options in search, as no transactions can be made on cards in these states -const cardHiddenFromSearchStates: number[] = [2, 4, 6]; +// 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 = { PERSONAL_SPEND: 'newDotPersonalSpend', From 3ef7c37a0f92d72261a838073de6e1aa94149eec Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Thu, 20 Feb 2025 17:27:39 +0800 Subject: [PATCH 8/8] update search filters for closed cards and updated hidden --- .../SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index b46106c70f3f..a4013e0795a5 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -73,8 +73,8 @@ function buildCardsData( selectedCards: string[], isClosedCards = false, ): ItemsGroupedBySelection { - // Filter condition to build different cards data for closed cards and individual cards based on the isClosedCards flag - const filterCondition = (card: Card) => (isClosedCards ? isCardClosed(card) : !isCardHiddenFromSearch(card)); + // 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) => filterCondition(card)) .map((card) => createCardFilterItem(card, personalDetailsList, selectedCards)); @@ -177,7 +177,7 @@ function buildCardFeedsData( 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) || isCardClosed(card))) + .filter(([cardKey, card]) => cardKey !== 'cardList' && isCard(card) && !isCardHiddenFromSearch(card)) .map(([cardKey]) => cardKey); const feedItem = createCardFeedItem({