diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index a78845f126d2..96a3037f2bab 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -15,6 +15,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -47,7 +48,9 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); @@ -326,7 +329,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } const onFiltersButtonPress = () => { - const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, cardList, reports, taxRates); + const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, allCards, reports, taxRates); SearchActions.updateAdvancedFilters(filterFormValues); Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index c90dcb2330e1..b6cfcec86d29 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -15,6 +15,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -71,10 +72,13 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = useMemo(() => getAllTaxRates(), []); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const {type, inputQuery: originalInputQuery} = queryJSON; const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); - const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates); + const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates, allCards); const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : ''; // The actual input text that the user sees @@ -107,9 +111,9 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps }, [queryText]); useEffect(() => { - const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates); + const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates, allCards); setAutocompleteSubstitutions(substitutionsMap); - }, [originalInputQuery, personalDetails, reports, taxRates]); + }, [allCards, originalInputQuery, personalDetails, reports, taxRates]); const onSearchQueryChange = useCallback( (userQuery: string) => { diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 3fe7cc9e2de4..88fdac712d7d 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -140,8 +140,11 @@ function SearchRouterList( const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const cardAutocompleteList = Object.values(cardList); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); + const cardAutocompleteList = Object.values(allCards); + const participantsAutocompleteList = useMemo(() => { if (!areOptionsInitialized) { return []; @@ -332,7 +335,7 @@ function SearchRouterList( return filteredCards.map((card) => ({ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - text: CardUtils.getCardDescription(card.cardID), + text: CardUtils.getCardDescription(card.cardID, allCards), autocompleteID: card.cardID.toString(), })); } @@ -355,6 +358,7 @@ function SearchRouterList( statusAutocompleteList, expenseTypes, cardAutocompleteList, + allCards, ]); const sortedRecentSearches = useMemo(() => { @@ -364,7 +368,7 @@ function SearchRouterList( const recentSearchesData = sortedRecentSearches?.slice(0, 5).map(({query, timestamp}) => { const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query); return { - text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, reports, taxRates) : query, + text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, reports, taxRates, allCards) : query, singleIcon: Expensicons.History, searchQuery: query, keyForList: timestamp, diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index 1394b5de4cf1..ce2bd6106b22 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -29,6 +29,7 @@ function buildSubstitutionsMap( personalDetails: OnyxTypes.PersonalDetailsList | undefined, reports: OnyxCollection, allTaxRates: Record, + cardList: OnyxTypes.CardList, ): SubstitutionMap { const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; @@ -61,7 +62,7 @@ function buildSubstitutionsMap( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID ) { - const displayValue = SearchQueryUtils.getFilterDisplayValue(filterKey, filterValue, personalDetails, reports); + const displayValue = SearchQueryUtils.getFilterDisplayValue(filterKey, filterValue, personalDetails, reports, cardList); // If displayValue === filterValue, then it means there is nothing to substitute, so we don't add any key to map if (displayValue !== filterValue) { diff --git a/src/hooks/usePaymentMethodState/types.ts b/src/hooks/usePaymentMethodState/types.ts index 260a9aec27cf..3bdcc09f3a2c 100644 --- a/src/hooks/usePaymentMethodState/types.ts +++ b/src/hooks/usePaymentMethodState/types.ts @@ -1,4 +1,4 @@ -import type {ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import type {AccountData} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -6,7 +6,7 @@ type FormattedSelectedPaymentMethodIcon = { icon: IconAsset; iconHeight?: number; iconWidth?: number; - iconStyles?: ViewStyle[]; + iconStyles?: StyleProp; iconSize?: number; }; diff --git a/src/languages/en.ts b/src/languages/en.ts index 7088d9df8a51..5c1b44d055ca 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4585,6 +4585,13 @@ const translations = { greaterThan: ({amount}: OptionalParam = {}) => `Greater than ${amount ?? ''}`, between: ({greaterThan, lessThan}: FiltersAmountBetweenParams) => `Between ${greaterThan} and ${lessThan}`, }, + card: { + expensify: 'Expensify', + individualCards: 'Individual cards', + cardFeeds: 'Card feeds', + cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) => + `All ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`, + }, current: 'Current', past: 'Past', submitted: 'Submitted', diff --git a/src/languages/es.ts b/src/languages/es.ts index 47e11bf716ac..4e3915ce0aa0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4629,6 +4629,13 @@ const translations = { link: 'Enlace', pinned: 'Fijado', unread: 'No leído', + card: { + expensify: 'Expensify', + individualCards: 'Tarjetas individuales', + cardFeeds: 'Flujos de tarjetas', + cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) => + `Todo ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`, + }, amount: { lessThan: ({amount}: OptionalParam = {}) => `Menos de ${amount ?? ''}`, greaterThan: ({amount}: OptionalParam = {}) => `Más que ${amount ?? ''}`, diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 9a71480019a6..080b5afc7aa3 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -63,16 +63,34 @@ function isCorporateCard(cardID: number) { * @param cardID * @returns string in format % - %. */ -function getCardDescription(cardID?: number) { +function getCardDescription(cardID?: number, cards: CardList = allCards) { if (!cardID) { return ''; } - const card = allCards[cardID]; + const card = cards[cardID]; if (!card) { return ''; } const cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? Localize.translateLocal('cardTransactions.notActivated') : card.lastFourPAN; - return cardDescriptor ? `${card.bank} - ${cardDescriptor}` : `${card.bank}`; + const humanReadableBankName = card.bank === CONST.EXPENSIFY_CARD.BANK ? CONST.EXPENSIFY_CARD.BANK : getCardFeedName(card.bank as CompanyCardFeed); + return cardDescriptor ? `${humanReadableBankName} - ${cardDescriptor}` : `${humanReadableBankName}`; +} + +function isCard(item: Card | Record): item is Card { + return 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; +} + +function mergeCardListWithWorkspaceFeeds(workspaceFeeds: Record, cardList = allCards) { + const feedCards: CardList = {...cardList}; + Object.values(workspaceFeeds ?? {}).forEach((currentCardFeed) => { + Object.values(currentCardFeed ?? {}).forEach((card) => { + if (!isCard(card)) { + return; + } + feedCards[card.cardID] = card; + }); + }); + return feedCards; } /** @@ -415,4 +433,6 @@ export { hasOnlyOneCardToAssign, checkIfNewFeedConnected, getDefaultCardName, + mergeCardListWithWorkspaceFeeds, + isCard, }; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index cbf25b787d95..6c261eb0ffce 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -504,7 +504,13 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { /** * Returns the human-readable "pretty" string for a specified filter value. */ -function getFilterDisplayValue(filterName: string, filterValue: string, personalDetails: OnyxTypes.PersonalDetailsList | undefined, reports: OnyxCollection) { +function getFilterDisplayValue( + filterName: string, + filterValue: string, + personalDetails: OnyxTypes.PersonalDetailsList | undefined, + reports: OnyxCollection, + cardList: OnyxTypes.CardList, +) { if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { // login can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -515,7 +521,7 @@ function getFilterDisplayValue(filterName: string, filterValue: string, personal if (Number.isNaN(cardID)) { return filterValue; } - return CardUtils.getCardDescription(cardID) || filterValue; + return CardUtils.getCardDescription(cardID, cardList) || filterValue; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) { return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filterValue}`]) || filterValue; @@ -538,6 +544,7 @@ function buildUserReadableQueryString( PersonalDetails: OnyxTypes.PersonalDetailsList | undefined, reports: OnyxCollection, taxRates: Record, + cardList: OnyxTypes.CardList, ) { const {type, status} = queryJSON; const filters = queryJSON.flatFilters; @@ -569,7 +576,7 @@ function buildUserReadableQueryString( } else { displayQueryFilters = queryFilter.map((filter) => ({ operator: filter.operator, - value: getFilterDisplayValue(key, filter.value.toString(), PersonalDetails, reports), + value: getFilterDisplayValue(key, filter.value.toString(), PersonalDetails, reports, cardList), })); } title += buildFilterValuesString(key, displayQueryFilters); diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 02eca4b9fbbc..493f0d195e55 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -15,6 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; +import * as CardUtils from '@libs/CardUtils'; import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; @@ -216,7 +217,7 @@ function getFilterCardDisplayTitle(filters: Partial, return filterValue ? Object.values(cards) .filter((card) => filterValue.includes(card.cardID.toString())) - .map((card) => card.bank) + .map((card) => CardUtils.getCardDescription(card.cardID, cards)) .join(', ') : undefined; } @@ -373,7 +374,9 @@ function AdvancedSearchFilters() { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const policyID = searchAdvancedFilters.policyID ?? '-1'; - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const taxRates = getAllTaxRates(); const personalDetails = usePersonalDetails(); @@ -410,7 +413,7 @@ function AdvancedSearchFilters() { const shouldDisplayCategoryFilter = shouldDisplayFilter(nonPersonalPolicyCategoryCount, areCategoriesEnabled, !!singlePolicyCategories); const shouldDisplayTagFilter = shouldDisplayFilter(tagListsUnpacked.length, areTagsEnabled, !!singlePolicyTagLists); - const shouldDisplayCardFilter = shouldDisplayFilter(Object.keys(cardList).length, areCardsEnabled); + const shouldDisplayCardFilter = shouldDisplayFilter(Object.keys(allCards).length, areCardsEnabled); const shouldDisplayTaxFilter = shouldDisplayFilter(Object.keys(taxRates).length, areTaxEnabled); let currentType = searchAdvancedFilters?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -485,7 +488,7 @@ function AdvancedSearchFilters() { if (!shouldDisplayCardFilter) { return; } - filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, cardList); + filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, allCards); } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED) { if (!shouldDisplayCardFilter) { return; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 9a6863f478f7..a4171953e2ea 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -1,15 +1,18 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import CardListItem from '@components/SelectionList/CardListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; -import type {Section} from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -17,68 +20,242 @@ import * as SearchActions from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {CompanyCardFeed} from '@src/types/onyx'; +import type {Card, CardList, CompanyCardFeed, WorkspaceCardsList} from '@src/types/onyx'; +import type {BankIcon} from '@src/types/onyx/Bank'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type CardFilterItem = Partial & {bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; isCardFeed?: boolean; correspondingCards?: string[]}; + +type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; + +function getReapeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Record) { + const repeatingBanks: string[] = []; + const banks: string[] = []; + const handleRepeatingBankNames = (bankName: string) => { + if (banks.includes(bankName) && !repeatingBanks.includes(bankName)) { + repeatingBanks.push(bankName); + } else { + banks.push(bankName); + } + }; + + workspaceCardFeedsKeys.forEach((cardFeedKey) => { + const bankName = cardFeedKey.split('_').at(2); + if (!bankName) { + return; + } + + handleRepeatingBankNames(bankName); + }); + Object.values(domainFeedsData).forEach((domainFeed) => { + handleRepeatingBankNames(domainFeed.bank); + }); + return repeatingBanks; +} + +function buildIndividualCardsData(workspaceCardFeeds: Record, userCardList: CardList, selectedCards: string[], iconStyles: StyleProp) { + const userAssignedCards = Object.values(userCardList ?? {}).map((card) => { + const isSelected = selectedCards.includes(card.cardID.toString()); + const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); + const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; + const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + + return { + lastFourPAN: card.lastFourPAN, + isVirtual: card?.nameValuePairs?.isVirtual, + text, + keyForList: card.cardID.toString(), + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: false, + }; + }); + + // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key + const allWorkspaceCards = Object.values(workspaceCardFeeds) + .filter((cardFeed) => !isEmptyObject(cardFeed)) + .flatMap((cardFeed) => { + return Object.values(cardFeed as Record) + .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID]) + .map((card) => { + const isSelected = selectedCards.includes(card.cardID.toString()); + const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); + const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; + const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + + return { + lastFourPAN: card.lastFourPAN, + isVirtual: card?.nameValuePairs?.isVirtual, + text, + keyForList: card.cardID.toString(), + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: false, + }; + }); + }); + return [...userAssignedCards, ...allWorkspaceCards]; +} + +function buildCardFeedsData( + workspaceCardFeeds: Record, + domainFeedsData: Record, + selectedCards: string[], + iconStyles: StyleProp, + translate: LocaleContextProps['translate'], +) { + const repeatingBanks = getReapeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); + const domainFeeds = Object.values(domainFeedsData).map((domainFeed) => { + const {domainName, bank, correspondingCardIDs} = domainFeed; + const isBankRepeating = repeatingBanks.includes(bank); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? domainName : undefined}); + + const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); + + const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed); + return { + text, + keyForList: `${domainName}-${bank}`, + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: true, + correspondingCards: correspondingCardIDs, + }; + }); + + const workspaceFeeds = Object.entries(workspaceCardFeeds) + .filter(([, cardFeed]) => !isEmptyObject(cardFeed)) + .map(([cardFeedKey, cardFeed]) => { + const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); + if (!representativeCard) { + return; + } + const {domainName, bank} = representativeCard; + const isBankRepeating = repeatingBanks.includes(bank); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; + const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); + const correspondingCardIDs = Object.keys(cardFeed ?? {}).filter((cardKey) => cardKey !== 'cardList'); + + const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); + + const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed); + return { + text, + keyForList: cardFeedKey, + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: true, + correspondingCards: correspondingCardIDs, + }; + }) + .filter((feed) => feed) as CardFilterItem[]; + + return [...domainFeeds, ...workspaceFeeds]; +} function SearchFiltersCardPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const currentCards = searchAdvancedFiltersForm?.cardID; - const [newCards, setNewCards] = useState(currentCards ?? []); + const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; + const [selectedCards, setSelectedCards] = useState(initiallySelectedCards ?? []); + + const individualCardsSectionData = useMemo( + () => buildIndividualCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, selectedCards, styles.cardIcon), + [workspaceCardFeeds, selectedCards, styles.cardIcon, userCardList], + ); + + const domainFeedsData = useMemo( + () => + Object.values(userCardList ?? {}).reduce((accumulator, currentCard) => { + // Cards in cardList can also be domain cards, we use them to compute domain feed + if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { + if (accumulator[currentCard.domainName]) { + accumulator[currentCard.domainName].correspondingCardIDs.push(currentCard.cardID.toString()); + } else { + accumulator[currentCard.domainName] = {domainName: currentCard.domainName, bank: currentCard.bank, correspondingCardIDs: [currentCard.cardID.toString()]}; + } + } + return accumulator; + }, {} as Record), + [userCardList], + ); + + const cardFeedsSectionData = useMemo( + () => buildCardFeedsData(workspaceCardFeeds ?? {}, domainFeedsData, selectedCards, styles.cardIcon, translate), + [domainFeedsData, workspaceCardFeeds, selectedCards, styles.cardIcon, translate], + ); + + const shouldShowSearchInput = cardFeedsSectionData.length + individualCardsSectionData.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; const sections = useMemo(() => { - const newSections: Section[] = []; - const cards = Object.values(cardList ?? {}) - .sort((a, b) => a.bank.localeCompare(b.bank)) - .map((card) => { - const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); - const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; - const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; - - return { - lastFourPAN: card.lastFourPAN, - isVirtual: card?.nameValuePairs?.isVirtual, - text, - keyForList: card.cardID.toString(), - isSelected: newCards.includes(card.cardID.toString()), - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles: [styles.cardIcon], - }, - }; - }); + const newSections = []; + newSections.push({ - title: undefined, - data: cards, - shouldShow: cards.length > 0, + title: translate('search.filters.card.cardFeeds'), + data: cardFeedsSectionData.filter((item) => item && item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + shouldShow: cardFeedsSectionData.length > 0, + }); + newSections.push({ + title: translate('search.filters.card.individualCards'), + data: individualCardsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + shouldShow: individualCardsSectionData.length > 0, }); return newSections; - }, [cardList, styles, newCards]); + }, [translate, cardFeedsSectionData, individualCardsSectionData, debouncedSearchTerm]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ - cardID: newCards, + cardID: selectedCards, }); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); - }, [newCards]); + }, [selectedCards]); const updateNewCards = useCallback( - (item: Partial) => { + (item: CardFilterItem) => { if (!item.keyForList) { return; } + + const isCardFeed = item?.isCardFeed && item?.correspondingCards; + if (item.isSelected) { - setNewCards(newCards.filter((card) => card !== item.keyForList)); + const newCardsObject = selectedCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); + setSelectedCards(newCardsObject); } else { - setNewCards([...newCards, item.keyForList]); + const newCardsObject = isCardFeed ? [...selectedCards, ...(item?.correspondingCards ?? [])] : [...selectedCards, item.keyForList]; + setSelectedCards(newCardsObject); } }, - [newCards], + [selectedCards], ); const footerContent = useMemo( @@ -108,7 +285,7 @@ function SearchFiltersCardPage() { }} /> - sections={sections} onSelectRow={updateNewCards} footerContent={footerContent} @@ -116,6 +293,12 @@ function SearchFiltersCardPage() { shouldShowTooltips canSelectMultiple ListItem={CardListItem} + shouldShowTextInput={shouldShowSearchInput} + textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined} + textInputValue={searchTerm} + onChangeText={(value) => { + setSearchTerm(value); + }} /> @@ -125,3 +308,4 @@ function SearchFiltersCardPage() { SearchFiltersCardPage.displayName = 'SearchFiltersCardPage'; export default SearchFiltersCardPage; +export {buildIndividualCardsData, buildCardFeedsData}; diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 5c93a3877ff6..2190377fd90a 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useContext, useLayoutEffect, useRef} from 'react'; +import React, {useCallback, useContext, useLayoutEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, ScrollViewProps, TextStyle, ViewStyle} from 'react-native'; @@ -19,6 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates, hasWorkspaceWithInvoices} from '@libs/PolicyUtils'; import {hasInvoiceReports} from '@libs/ReportUtils'; @@ -68,6 +69,9 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const taxRates = getAllTaxRates(); const {isOffline} = useNetwork(); @@ -122,7 +126,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { let title = item.name; if (title === item.query) { const jsonQuery = SearchQueryUtils.buildSearchQueryJSON(item.query) ?? ({} as SearchQueryJSON); - title = SearchQueryUtils.buildUserReadableQueryString(jsonQuery, personalDetails, reports, taxRates); + title = SearchQueryUtils.buildUserReadableQueryString(jsonQuery, personalDetails, reports, taxRates, allCards); } const baseMenuItem: SavedSearchMenuItem = { @@ -228,7 +232,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { const activeItemIndex = isCannedQuery ? typeMenuItems.findIndex((item) => item.type === type) : -1; if (shouldUseNarrowLayout) { - const title = searchName ?? (isCannedQuery ? undefined : SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates)); + const title = searchName ?? (isCannedQuery ? undefined : SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates, allCards)); return ( ; }; /** Bank names */ diff --git a/src/types/onyx/PaymentMethod.ts b/src/types/onyx/PaymentMethod.ts index b95f890939eb..f473dfb0e8fc 100644 --- a/src/types/onyx/PaymentMethod.ts +++ b/src/types/onyx/PaymentMethod.ts @@ -1,4 +1,4 @@ -import type {ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import type IconAsset from '@src/types/utils/IconAsset'; import type BankAccount from './BankAccount'; import type Fund from './Fund'; @@ -21,7 +21,7 @@ type PaymentMethod = (BankAccount | Fund) & { iconWidth?: number; /** Icon wrapper styles */ - iconStyles?: ViewStyle[]; + iconStyles?: StyleProp; }; export default PaymentMethod; diff --git a/tests/unit/Search/buildCardFilterDataTest.ts b/tests/unit/Search/buildCardFilterDataTest.ts new file mode 100644 index 000000000000..3c9e92348c06 --- /dev/null +++ b/tests/unit/Search/buildCardFilterDataTest.ts @@ -0,0 +1,244 @@ +// The cards_ object keys don't follow normal naming convention, so to test this reliably we have to disable liner + +/* eslint-disable @typescript-eslint/naming-convention */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {buildCardFeedsData, buildIndividualCardsData} from '@pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage'; +import type {CardList, WorkspaceCardsList} from '@src/types/onyx'; + +jest.mock('@libs/PolicyUtils', () => { + return { + getPolicy(policyID: string) { + switch (policyID) { + case '1': + return {name: ''}; + case '2': + return {name: 'test1'}; + case '3': + return {name: 'test2'}; + default: + return {name: ''}; + } + }, + }; +}); + +const workspaceCardFeeds = { + cards_18680694_vcf: { + '21593492': { + accountID: 1, + bank: 'vcf', + cardID: 21593492, + cardName: '480801XXXXXX9411', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '9411', + }, + '21604933': { + accountID: 1, + bank: 'vcf', + cardID: 21604933, + cardName: '480801XXXXXX1601', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '1601', + }, + '21638320': { + accountID: 1, + bank: 'vcf', + cardID: 21638320, + cardName: '480801XXXXXX2617', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '2617', + }, + '21638598': { + accountID: 1, + bank: 'vcf', + cardID: 21638598, + cardName: '480801XXXXXX2111', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '2111', + }, + cardList: { + test: '231:1111111', + }, + }, + 'cards_18755165_Expensify Card': { + '21588678': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588678, + cardName: '455594XXXXXX1138', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '1138', + }, + '21588684': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588684, + cardName: '', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '', + }, + }, + 'cards_11111_Expensify Card': { + '21589168': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589168, + cardName: '455594XXXXXX4163', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '4163', + }, + '21589182': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589182, + cardName: '', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '', + }, + '21589202': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589202, + cardName: '455594XXXXXX6232', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '6232', + }, + '21638322': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21638322, + cardName: '', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '', + }, + }, +}; + +const cardList = { + '21588678': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588678, + cardName: '455594XXXXXX1138', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '1138', + }, + '21588684': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588684, + cardName: '', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '', + }, + '21589202': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589202, + cardName: '455594XXXXXX6232', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '6232', + }, + '21604933': { + accountID: 1, + bank: 'vcf', + cardID: 21604933, + cardName: '480801XXXXXX1601', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '1601', + }, + '11111111': { + accountID: 1, + bank: 'Expensify Card', + cardID: 11111111, + cardName: '455594XXXXXX1138', + domainName: 'testDomain', + lastFourPAN: '1138', + }, +}; + +const domainFeedDataMock = {testDomain: {domainName: 'testDomain', bank: 'Expensify Card', correspondingCardIDs: ['11111111']}}; + +function translateMock(key: string, obj: {cardFeedBankName: string; cardFeedLabel: string}) { + if (key === 'search.filters.card.expensify') { + return 'Expensify'; + } + return `All ${obj.cardFeedBankName}${obj.cardFeedLabel ? ` - ${obj.cardFeedLabel}` : ''}`; +} +const translateMock1 = jest.fn(); + +describe('buildIndividualCardsData', () => { + const result = buildIndividualCardsData(workspaceCardFeeds as unknown as Record, cardList as unknown as CardList, ['21588678'], {}); + + it("Builds all individual cards and doesn't generate duplicates", () => { + expect(result.length).toEqual(11); + + // Check if Expensify card was built correctly + const expensifyCard = result.find((card) => card.keyForList === '21588678'); + expect(expensifyCard).toMatchObject({ + text: 'Expensify Card', + lastFourPAN: '1138', + isSelected: true, + }); + + // Check if company card was built correctly + const companyCard = result.find((card) => card.keyForList === '21604933'); + expect(companyCard).toMatchObject({ + text: '480801XXXXXX1601', + lastFourPAN: '1601', + isSelected: false, + }); + }); +}); + +describe('buildIndividualCardsData with empty argument objects', () => { + it('Returns empty array when cardList and workspaceCardFeeds are empty', () => { + const result = buildIndividualCardsData({}, {}, [], {}); + expect(result).toEqual([]); + }); +}); + +describe('buildCardFeedsData', () => { + const result = buildCardFeedsData( + workspaceCardFeeds as unknown as Record, + domainFeedDataMock, + [], + {}, + translateMock1 as LocaleContextProps['translate'], + ); + + it('Buids domain card feed properly', () => { + // Check if domain card feed was built properly + expect(result.at(0)).toMatchObject({ + isCardFeed: true, + correspondingCards: ['11111111'], + }); + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'testDomain'}); + // Check if workspace card feed that comes from company cards was built properly. + expect(result.at(1)).toMatchObject({ + isCardFeed: true, + correspondingCards: ['21593492', '21604933', '21638320', '21638598'], + }); + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: 'Visa', cardFeedLabel: undefined}); + // Check if workspace card feed that comes from expensify cards was built properly + expect(result.at(2)).toMatchObject({ + isCardFeed: true, + correspondingCards: ['21588678', '21588684'], + }); + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test1'}); + + // Check if workspace card feed that comes from expensify cards was built properly. + expect(result.at(3)).toMatchObject({ + isCardFeed: true, + correspondingCards: ['21589168', '21589182', '21589202', '21638322'], + }); + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test2'}); + }); +}); + +describe('buildIndividualCardsData with empty argument objects', () => { + it('Return empty array when domainCardFeeds and workspaceCardFeeds are empty', () => { + const result = buildCardFeedsData({}, {}, [], {}, translateMock as LocaleContextProps['translate']); + expect(result).toEqual([]); + }); +}); diff --git a/tests/unit/Search/buildSubstitutionsMapTest.ts b/tests/unit/Search/buildSubstitutionsMapTest.ts index 03f4f13645df..4015435d3c12 100644 --- a/tests/unit/Search/buildSubstitutionsMapTest.ts +++ b/tests/unit/Search/buildSubstitutionsMapTest.ts @@ -5,14 +5,6 @@ import {buildSubstitutionsMap} from '@src/components/Search/SearchRouter/buildSu import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -jest.mock('@libs/CardUtils', () => { - return { - getCardDescription(cardID: number) { - return cardID; - }, - }; -}); - jest.mock('@libs/ReportUtils', () => { return { parseReportRouteParams: jest.fn(() => ({})), @@ -53,18 +45,26 @@ const taxRatesMock = { TAX_1: ['id_TAX_1'], } as Record; +const cardListMock = { + '11223344': { + state: 1, + bank: 'vcf', + lastFourPAN: '1234', + }, +} as unknown as OnyxTypes.CardList; + describe('buildSubstitutionsMap should return correct substitutions map', () => { test('when there were no substitutions', () => { const userQuery = 'foo bar'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock, {}); expect(result).toStrictEqual({}); }); test('when query has a single substitution', () => { const userQuery = 'foo from:12345'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock, {}); expect(result).toStrictEqual({ 'from:John Doe': '12345', @@ -74,13 +74,13 @@ describe('buildSubstitutionsMap should return correct substitutions map', () => test('when query has multiple substitutions of different types', () => { const userQuery = 'from:78901,12345 to:nonExistingGuy@mail.com cardID:11223344 in:rep123 taxRate:id_TAX_1'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock, cardListMock); expect(result).toStrictEqual({ 'from:Jane Doe': '78901', 'from:John Doe': '12345', 'in:Report 1': 'rep123', - 'cardID:11223344': '11223344', + 'cardID:Visa - 1234': '11223344', 'taxRate:TAX_1': 'id_TAX_1', }); });