diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index fe7b49314ae0..00d32fa22676 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -17,11 +17,18 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as SearchActions from '@libs/actions/Search'; +import { + approveMoneyRequestOnSearch, + deleteMoneyRequestOnSearch, + exportSearchItemsToCSV, + payMoneyRequestOnSearch, + unholdMoneyRequestOnSearch, + updateAdvancedFilters, +} from '@libs/actions/Search'; +import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getAllTaxRates} from '@libs/PolicyUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import {getAllTaxRates, hasVBBA} from '@libs/PolicyUtils'; +import {buildFilterFormValuesFromQuery, isCannedSearchQuery} from '@libs/SearchQueryUtils'; import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -50,7 +57,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(() => 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); @@ -85,7 +94,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } setIsDeleteExpensesConfirmModalVisible(false); - SearchActions.deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); + deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); // Translations copy for delete modal depends on amount of selected items, // We need to wait for modal to fully disappear before clearing them to avoid translation flicker between singular vs plural @@ -125,7 +134,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const reportIDList = !selectedReports.length ? Object.values(selectedTransactions).map((transaction) => transaction.reportID) : selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; - SearchActions.approveMoneyRequestOnSearch(hash, reportIDList, transactionIDList); + approveMoneyRequestOnSearch(hash, reportIDList, transactionIDList); }, }); } @@ -164,9 +173,9 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { return; } - const hasVBBA = PolicyUtils.hasVBBA(policyID); + const hasPolicyVBBA = hasVBBA(policyID); - if (lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && !hasVBBA) { + if (lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && !hasPolicyVBBA) { Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute})); return; } @@ -182,7 +191,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { })) ) as PaymentData[]; - SearchActions.payMoneyRequestOnSearch(hash, paymentData, transactionIDList); + payMoneyRequestOnSearch(hash, paymentData, transactionIDList); }, }); } @@ -199,8 +208,14 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } const reportIDList = selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; - SearchActions.exportSearchItemsToCSV( - {query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']}, + exportSearchItemsToCSV( + { + query: status, + jsonQuery: JSON.stringify(queryJSON), + reportIDList, + transactionIDList: selectedTransactionsKeys, + policyIDs: activeWorkspaceID ? [activeWorkspaceID] : [''], + }, () => { setIsDownloadErrorModalVisible(true); }, @@ -241,7 +256,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { return; } - SearchActions.unholdMoneyRequestOnSearch(hash, selectedTransactionsKeys); + unholdMoneyRequestOnSearch(hash, selectedTransactionsKeys); }, }); } @@ -284,20 +299,20 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { return options; }, [ - queryJSON, - status, selectedTransactionsKeys, selectedTransactions, + isOffline, + selectedReports, translate, hash, + lastPaymentMethods, + status, + queryJSON, + activeWorkspaceID, theme.icon, styles.colorMuted, styles.fontWeightNormal, - isOffline, - activeWorkspaceID, - selectedReports, styles.textWrap, - lastPaymentMethods, ]); if (shouldUseNarrowLayout) { @@ -346,13 +361,13 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const onFiltersButtonPress = () => { hideProductTrainingTooltip(); - const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, cardList, reports, taxRates); - SearchActions.updateAdvancedFilters(filterFormValues); + const filterFormValues = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, allCards, reports, taxRates); + updateAdvancedFilters(filterFormValues); Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); }; - const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); + const isCannedQuery = isCannedSearchQuery(queryJSON); return ( <> diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index d9884b1c1efe..21452d358e62 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -14,14 +14,15 @@ import type {SelectionListHandle} from '@components/SelectionList/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as SearchActions from '@libs/actions/Search'; +import {navigateToAndOpenReport} from '@libs/actions/Report'; +import {clearAllFilters} from '@libs/actions/Search'; +import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import * as SearchAutocompleteUtils from '@libs/SearchAutocompleteUtils'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import {getAutocompleteQueryWithComma, getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; +import {buildUserReadableQueryString, getQueryWithUpdatedValues, isCannedSearchQuery, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import variables from '@styles/variables'; -import * as ReportUserActions from '@userActions/Report'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -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(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const {type, inputQuery: originalInputQuery} = queryJSON; - const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); - const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates); + const isCannedQuery = isCannedSearchQuery(queryJSON); + const queryText = buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates, allCards); const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : ''; // The actual input text that the user sees @@ -107,13 +111,13 @@ 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) => { - const updatedUserQuery = SearchAutocompleteUtils.getAutocompleteQueryWithComma(textInputValue, userQuery); + const updatedUserQuery = getAutocompleteQueryWithComma(textInputValue, userQuery); setTextInputValue(updatedUserQuery); setAutocompleteQueryValue(updatedUserQuery); @@ -132,7 +136,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const submitSearch = useCallback( (queryString: SearchQueryString) => { const queryWithSubstitutions = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); - const updatedQuery = SearchQueryUtils.getQueryWithUpdatedValues(queryWithSubstitutions, queryJSON.policyID); + const updatedQuery = getQueryWithUpdatedValues(queryWithSubstitutions, queryJSON.policyID); if (!updatedQuery) { return; } @@ -140,7 +144,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: updatedQuery})); if (updatedQuery !== originalInputQuery) { - SearchActions.clearAllFilters(); + clearAllFilters(); setTextInputValue(''); setAutocompleteQueryValue(''); setIsAutocompleteListVisible(false); @@ -157,8 +161,8 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { - const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue); - onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); + const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + onSearchQueryChange(`${trimmedUserSearchQuery}${sanitizeSearchValue(item.searchQuery)} `); if (item.mapKey && item.autocompleteID) { const substitutions = {...autocompleteSubstitutions, [item.mapKey]: item.autocompleteID}; @@ -171,7 +175,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps } else if (item?.reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); } else if ('login' in item) { - ReportUserActions.navigateToAndOpenReport(item.login ? [item.login] : [], false); + navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, [autocompleteSubstitutions, onSearchQueryChange, submitSearch, textInputValue], diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 6833879a05ed..277cde98e94b 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -17,9 +17,10 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as CardUtils from '@libs/CardUtils'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; -import type {SearchOption} from '@libs/OptionsListUtils'; +import {searchInServer} from '@libs/actions/Report'; +import {getCardDescription, isCard, isCardIssued, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; +import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; +import type {Options, SearchOption} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -29,11 +30,10 @@ import { getAutocompleteRecentTags, getAutocompleteTags, getAutocompleteTaxList, + getQueryWithoutAutocompletedPart, parseForAutocomplete, } from '@libs/SearchAutocompleteUtils'; -import * as SearchAutocompleteUtils from '@libs/SearchAutocompleteUtils'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; -import * as ReportUserActions from '@userActions/Report'; +import {buildSearchQueryJSON, buildUserReadableQueryString, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -47,7 +47,7 @@ type AutocompleteItemData = { mapKey?: SearchFilterKey; }; -type GetAdditionalSectionsCallback = (options: OptionsListUtils.Options) => Array> | undefined; +type GetAdditionalSectionsCallback = (options: Options) => Array> | undefined; type SearchRouterListProps = { /** Value of TextInput */ @@ -139,7 +139,7 @@ function SearchRouterList( if (!areOptionsInitialized) { return defaultListOptions; } - return OptionsListUtils.getSearchOptions(options, betas ?? []); + return getSearchOptions(options, betas ?? []); }, [areOptionsInitialized, betas, options]); const [isInitialRender, setIsInitialRender] = useState(true); @@ -148,14 +148,17 @@ 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(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); + const cardAutocompleteList = Object.values(allCards); + const participantsAutocompleteList = useMemo(() => { if (!areOptionsInitialized) { return []; } - const filteredOptions = OptionsListUtils.getValidOptions( + const filteredOptions = getValidOptions( { reports: options.reports, personalDetails: options.personalDetails, @@ -335,16 +338,14 @@ function SearchRouterList( } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { const filteredCards = cardAutocompleteList - .filter( - (card) => - card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(CardUtils.getCardDescription(card.cardID).toLowerCase()), - ) + .filter((card) => isCard(card) && isCardIssued(card)) + .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(getCardDescription(card.cardID).toLowerCase())) .sort() .slice(0, 10); return filteredCards.map((card) => ({ filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.CARD_ID, - text: CardUtils.getCardDescription(card.cardID), + text: getCardDescription(card.cardID, allCards), autocompleteID: card.cardID.toString(), mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, })); @@ -368,6 +369,7 @@ function SearchRouterList( statusAutocompleteList, expenseTypes, cardAutocompleteList, + allCards, ]); const sortedRecentSearches = useMemo(() => { @@ -375,9 +377,9 @@ function SearchRouterList( }, [recentSearches]); const recentSearchesData = sortedRecentSearches?.slice(0, 5).map(({query, timestamp}) => { - const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query); + const searchQueryJSON = buildSearchQueryJSON(query); return { - text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, reports, taxRates) : query, + text: searchQueryJSON ? buildUserReadableQueryString(searchQueryJSON, personalDetails, reports, taxRates, allCards) : query, singleIcon: Expensicons.History, searchQuery: query, keyForList: timestamp, @@ -397,7 +399,7 @@ function SearchRouterList( Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); const filteredOptions = filterOptions(autocompleteQueryValue); - const orderedOptions = OptionsListUtils.combineOrderingOfReportsAndPersonalDetails(filteredOptions, autocompleteQueryValue, { + const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filteredOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatroomsOverThreads: true, }); @@ -411,7 +413,7 @@ function SearchRouterList( }, [autocompleteQueryValue, filterOptions, searchOptions]); useEffect(() => { - ReportUserActions.searchInServer(autocompleteQueryValue.trim()); + searchInServer(autocompleteQueryValue.trim()); }, [autocompleteQueryValue]); /* Sections generation */ @@ -458,8 +460,8 @@ function SearchRouterList( return; } - const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(autocompleteQueryValue); - setTextQuery(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(autocompleteQueryValue); + setTextQuery(`${trimmedUserSearchQuery}${sanitizeSearchValue(focusedItem.searchQuery)} `); updateAutocompleteSubstitutions(focusedItem); }, [autocompleteQueryValue, setTextQuery, updateAutocompleteSubstitutions], diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index 1394b5de4cf1..b81faca6f5fb 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -1,9 +1,9 @@ import type {OnyxCollection} from 'react-native-onyx'; import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; -import * as parser from '@libs/SearchParser/autocompleteParser'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import {parse} from '@libs/SearchParser/autocompleteParser'; +import {getFilterDisplayValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; -import type * as OnyxTypes from '@src/types/onyx'; +import type {CardList, PersonalDetailsList, Report} from '@src/types/onyx'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; @@ -26,11 +26,12 @@ const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${fi */ function buildSubstitutionsMap( query: string, - personalDetails: OnyxTypes.PersonalDetailsList | undefined, - reports: OnyxCollection, + personalDetails: PersonalDetailsList | undefined, + reports: OnyxCollection, allTaxRates: Record, + cardList: CardList, ): SubstitutionMap { - const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; + const parsedQuery = parse(query) as {ranges: SearchAutocompleteQueryRange[]}; const searchAutocompleteQueryRanges = parsedQuery.ranges; @@ -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 = 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/components/SelectionList/CardListItem.tsx b/src/components/SelectionList/Search/CardListItem.tsx similarity index 52% rename from src/components/SelectionList/CardListItem.tsx rename to src/components/SelectionList/Search/CardListItem.tsx index 0e887d1d30db..a11da62979cd 100644 --- a/src/components/SelectionList/CardListItem.tsx +++ b/src/components/SelectionList/Search/CardListItem.tsx @@ -1,18 +1,26 @@ import {Str} from 'expensify-common'; import React, {useCallback} from 'react'; import {View} from 'react-native'; +import Avatar from '@components/Avatar'; import Icon from '@components/Icon'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import SelectCircle from '@components/SelectCircle'; +import BaseListItem from '@components/SelectionList/BaseListItem'; +import type {BaseListItemProps, ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; import type {BankIcon} from '@src/types/onyx/Bank'; -import BaseListItem from './BaseListItem'; -import type {BaseListItemProps, ListItem} from './types'; -type CardListItemProps = BaseListItemProps; +type AdditionalCardProps = {shouldShowOwnersAvatar?: boolean; cardOwnerPersonalDetails?: PersonalDetails; bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; cardName?: string}; +type CardListItemProps = BaseListItemProps; function CardListItem({ item, @@ -28,7 +36,9 @@ function CardListItem({ shouldSyncFocus, }: CardListItemProps) { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const theme = useTheme(); const handleCheckboxPress = useCallback(() => { if (onCheckboxPress) { @@ -38,10 +48,18 @@ function CardListItem({ } }, [item, onCheckboxPress, onSelectRow]); + const ownersAvatar = { + source: item.cardOwnerPersonalDetails?.avatar ?? FallbackAvatar, + id: item.cardOwnerPersonalDetails?.accountID ?? -1, + type: CONST.ICON_TYPE_AVATAR, + name: item.cardOwnerPersonalDetails?.displayName ?? '', + fallbackIcon: item.cardOwnerPersonalDetails?.fallbackIcon, + }; + const subtitleText = - `${item.lastFourPAN ? `${translate('paymentMethodList.accountLastFour')} ${item.lastFourPAN}` : ''}` + - `${item.lastFourPAN && item.isVirtual ? ` ${CONST.DOT_SEPARATOR} ` : ''}` + - `${item.isVirtual ? translate('workspace.expensifyCard.virtual') : ''}`; + `${item.lastFourPAN ? `${item.lastFourPAN}` : ''}` + + `${item.cardName ? ` ${CONST.DOT_SEPARATOR} ${item.cardName}` : ''}` + + `${item.isVirtual ? ` ${CONST.DOT_SEPARATOR} ${translate('workspace.expensifyCard.virtual')}` : ''}`; return ( ({ <> {!!item.bankIcon && ( - + {item.shouldShowOwnersAvatar ? ( + + + + + + + + + + + ) : ( + + )} )} @@ -115,3 +165,4 @@ function CardListItem({ CardListItem.displayName = 'CardListItem'; export default CardListItem; +export type {AdditionalCardProps}; 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 9e46f12212fd..e976ae1f67db 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4725,6 +4725,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 1a118f6fe510..ae32a90bbcec 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4768,6 +4768,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/API/types.ts b/src/libs/API/types.ts index 7b8c6df92a22..7583547bf1e5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -2,6 +2,8 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type {SageIntacctMappingValue} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; +// We have to disable esling for this import because otherwise the import will be 300 lines long +// eslint-disable-next-line no-restricted-syntax import type * as Parameters from './parameters'; import type SignInUserParams from './parameters/SignInUserParams'; import type UpdateBeneficialOwnersForBankAccountParams from './parameters/UpdateBeneficialOwnersForBankAccountParams'; @@ -949,6 +951,7 @@ const READ_COMMANDS = { OPEN_POLICY_COMPANY_CARDS_FEED: 'OpenPolicyCompanyCardsFeed', OPEN_POLICY_COMPANY_CARDS_PAGE: 'OpenPolicyCompanyCardsPage', OPEN_POLICY_EDIT_CARD_LIMIT_TYPE_PAGE: 'OpenPolicyEditCardLimitTypePage', + OPEN_SEARCH_FILTERS_CARD_PAGE: 'OpenSearchFiltersCardPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', @@ -1021,6 +1024,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; [READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_FEED]: Parameters.OpenPolicyCompanyCardsFeedParams; [READ_COMMANDS.OPEN_POLICY_EDIT_CARD_LIMIT_TYPE_PAGE]: Parameters.OpenPolicyEditCardLimitTypePageParams; + [READ_COMMANDS.OPEN_SEARCH_FILTERS_CARD_PAGE]: null; [READ_COMMANDS.OPEN_POLICY_PROFILE_PAGE]: Parameters.OpenPolicyProfilePageParams; [READ_COMMANDS.OPEN_POLICY_INITIAL_PAGE]: Parameters.OpenPolicyInitialPageParams; [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index d13500913c4f..eae4b56e9cd7 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -15,8 +15,9 @@ import type {CompanyCardNicknames, CompanyFeeds, DirectCardFeedData} from '@src/ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; import localeCompare from './LocaleCompare'; -import * as Localize from './Localize'; -import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import {translateLocal} from './Localize'; +import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; +import {getPolicy} from './PolicyUtils'; let allCards: OnyxValues[typeof ONYXKEYS.CARD_LIST] = {}; Onyx.connect({ @@ -73,16 +74,38 @@ 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 cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? translateLocal('cardTransactions.notActivated') : card.lastFourPAN; + const humanReadableBankName = card.bank === CONST.EXPENSIFY_CARD.BANK ? CONST.EXPENSIFY_CARD.BANK : getBankName(card.bank as CompanyCardFeed); + return cardDescriptor ? `${humanReadableBankName} - ${cardDescriptor}` : `${humanReadableBankName}`; +} + +function isCard(item: Card | Record): item is Card { + return typeof item === 'object' && 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; +} + +function isCardIssued(card: Card) { + return !!card?.nameValuePairs?.isVirtual || card?.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED; +} + +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; } /** @@ -149,7 +172,7 @@ function maskCard(lastFour = ''): string { * @param feed - card feed. * @returns - The masked card string. */ -function maskCardNumber(cardName: string, feed: string | undefined): string { +function maskCardNumber(cardName: string | undefined, feed: string | undefined): string { if (!cardName || cardName === '') { return ''; } @@ -216,8 +239,8 @@ function sortCardsByCardholderName(cardsList: OnyxEntry, per const userA = cardA.accountID ? personalDetails?.[cardA.accountID] ?? {} : {}; const userB = cardB.accountID ? personalDetails?.[cardB.accountID] ?? {} : {}; - const aName = PersonalDetailsUtils.getDisplayNameOrDefault(userA); - const bName = PersonalDetailsUtils.getDisplayNameOrDefault(userB); + const aName = getDisplayNameOrDefault(userA); + const bName = getDisplayNameOrDefault(userB); return localeCompare(aName, bName); }); @@ -272,7 +295,7 @@ function getCompanyFeeds(cardFeeds: OnyxEntry, shouldFilterOutRemoved ); } -function getCardFeedName(feedType: CompanyCardFeed): string { +function getBankName(feedType: CompanyCardFeed): string { const feedNamesMapping = { [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: 'Visa', [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: 'Mastercard', @@ -323,7 +346,7 @@ function getCustomOrFormattedFeedName(feed?: CompanyCardFeed, companyCardNicknam return ''; } - const formattedFeedName = Localize.translateLocal('workspace.companyCards.feedName', {feedName: getCardFeedName(feed)}); + const formattedFeedName = translateLocal('workspace.companyCards.feedName', {feedName: getBankName(feed)}); return customFeedName ?? formattedFeedName; } @@ -418,6 +441,16 @@ function getAllCardsForWorkspace(workspaceAccountID: number): CardList { return cards; } +const getDescriptionForPolicyDomainCard = (domainName: string): string => { + // A domain name containing a policyID indicates that this is a workspace feed + const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]; + if (policyID) { + const policy = getPolicy(policyID.toUpperCase()); + return policy?.name ?? domainName; + } + return domainName; +}; + export { isExpensifyCard, isCorporateCard, @@ -435,8 +468,8 @@ export { getEligibleBankAccountsForCard, sortCardsByCardholderName, getCardFeedIcon, + getBankName, isSelectedFeedExpired, - getCardFeedName, getCompanyFeeds, isCustomFeed, getBankCardDetailsImage, @@ -447,5 +480,9 @@ export { hasOnlyOneCardToAssign, checkIfNewFeedConnected, getDefaultCardName, + mergeCardListWithWorkspaceFeeds, + isCard, + getDescriptionForPolicyDomainCard, getAllCardsForWorkspace, + isCardIssued, }; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 0ee2d1ced8ae..a71fb32c75e5 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -533,7 +533,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 @@ -544,7 +550,7 @@ function getFilterDisplayValue(filterName: string, filterValue: string, personal if (Number.isNaN(cardID)) { return filterValue; } - return getCardDescription(cardID) || filterValue; + return getCardDescription(cardID, cardList) || filterValue; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) { return getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filterValue}`]) || filterValue; @@ -567,6 +573,7 @@ function buildUserReadableQueryString( PersonalDetails: OnyxTypes.PersonalDetailsList | undefined, reports: OnyxCollection, taxRates: Record, + cardList: OnyxTypes.CardList, ) { const {type, status} = queryJSON; const filters = queryJSON.flatFilters; @@ -598,7 +605,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(getUserFriendlyKey(key), displayQueryFilters); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 61597eb5949d..39cb9bc63e34 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -6,14 +6,14 @@ import type {PaymentData, SearchQueryJSON} from '@components/Search/types'; import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import * as API from '@libs/API'; import type {ExportSearchItemsToCSVParams, SubmitReportParams} from '@libs/API/parameters'; -import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import * as ApiUtils from '@libs/ApiUtils'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getCommandURL} from '@libs/ApiUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {rand64} from '@libs/NumberUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReportUtils from '@libs/ReportUtils'; +import {getSubmitToAccountID} from '@libs/PolicyUtils'; +import {hasHeldExpenses} from '@libs/ReportUtils'; import {isReportListItemType, isTransactionListItemType} from '@libs/SearchUIUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; @@ -21,7 +21,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type {LastPaymentMethod, SearchResults} from '@src/types/onyx'; import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; -import * as Report from './Report'; +import {openReport} from './Report'; let currentUserEmail: string; Onyx.connect({ @@ -53,7 +53,7 @@ function handleActionButtonPress(hash: number, item: TransactionListItemType | R // We need the transactionID to display the loading indicator for that list item's action. const transactionID = isTransactionListItemType(item) ? [item.transactionID] : undefined; const allReportTransactions = (isReportListItemType(item) ? item.transactions : [item]) as SearchTransaction[]; - const hasHeldExpense = ReportUtils.hasHeldExpenses('', allReportTransactions); + const hasHeldExpense = hasHeldExpenses('', allReportTransactions); if (hasHeldExpense) { goToItem(); @@ -223,6 +223,15 @@ function deleteSavedSearch(hash: number) { API.write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash}, {optimisticData, failureData, successData}); } +function openSearchFiltersCardPage() { + const optimisticData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, value: null}]; + + const successData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, value: null}]; + + const failureData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, value: null}]; + API.read(READ_COMMANDS.OPEN_SEARCH_FILTERS_CARD_PAGE, null, {optimisticData, successData, failureData}); +} + function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: number}) { const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON); const {flatFilters, ...queryJSONWithoutFlatFilters} = queryJSON; @@ -240,7 +249,7 @@ function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: numbe * In that case, when users select the search result row, we need to create the transaction thread on the fly and update the search result with the new transactionThreadReport */ function createTransactionThread(hash: number, transactionID: string, reportID: string, moneyRequestReportActionID: string) { - Report.openReport(reportID, '', [currentUserEmail], undefined, moneyRequestReportActionID); + openReport(reportID, '', [currentUserEmail], undefined, moneyRequestReportActionID); const onyxUpdate: Record>> = { data: { @@ -278,7 +287,7 @@ function submitMoneyRequestOnSearch(hash: number, reportList: SearchReport[], po const report = (reportList.at(0) ?? {}) as SearchReport; const parameters: SubmitReportParams = { reportID: report.reportID, - managerAccountID: PolicyUtils.getSubmitToAccountID(policy.at(0), report) ?? report?.managerID, + managerAccountID: getSubmitToAccountID(policy.at(0), report) ?? report?.managerID, reportActionID: rand64(), }; @@ -300,7 +309,7 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], trans }, ]; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}); - const failureData: OnyxUpdate[] = createOnyxData({errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); + const failureData: OnyxUpdate[] = createOnyxData({errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); const finallyData: OnyxUpdate[] = createOnyxData({isActionLoading: false}); API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH, {hash, reportIDList}, {optimisticData, failureData, finallyData}); @@ -320,7 +329,7 @@ function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], trans ]; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}); - const failureData: OnyxUpdate[] = createOnyxData({errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); + const failureData: OnyxUpdate[] = createOnyxData({errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); const finallyData: OnyxUpdate[] = createOnyxData({isActionLoading: false}); // eslint-disable-next-line rulesdir/no-api-side-effects-method @@ -367,7 +376,7 @@ function exportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDLi } }); - fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV}), 'Expensify.csv', '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); + fileDownload(getCommandURL({command: WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV}), 'Expensify.csv', '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); } /** @@ -411,4 +420,5 @@ export { approveMoneyRequestOnSearch, handleActionButtonPress, submitMoneyRequestOnSearch, + openSearchFiltersCardPage, }; diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index ccb4bbfe5da1..d1c4b3649c7e 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -15,15 +15,16 @@ import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; +import {clearAllFilters, saveSearch} from '@libs/actions/Search'; +import {getCardDescription, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {getAllTaxRates, getTagNamesFromTagsLists, isPolicyFeatureEnabled} from '@libs/PolicyUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; -import * as SearchUIUtils from '@libs/SearchUIUtils'; -import * as SearchActions from '@userActions/Search'; +import {getReportName} from '@libs/ReportUtils'; +import {buildCannedSearchQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, isCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {getExpenseTypeTranslationKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -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) => getCardDescription(card.cardID, cards)) .join(', ') : undefined; } @@ -230,7 +231,7 @@ function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails: return ''; } - return PersonalDetailsUtils.createDisplayName(personalDetail.login ?? '', personalDetail); + return createDisplayName(personalDetail.login ?? '', personalDetail); }) .filter(Boolean) .join(', '); @@ -339,7 +340,7 @@ function getFilterExpenseDisplayTitle(filters: Partial filterValue.includes(expenseType)) - .map((expenseType) => translate(SearchUIUtils.getExpenseTypeTranslationKey(expenseType))) + .map((expenseType) => translate(getExpenseTypeTranslationKey(expenseType))) .join(', ') : undefined; } @@ -347,7 +348,7 @@ function getFilterExpenseDisplayTitle(filters: Partial, _: LocaleContextProps['translate'], reports?: OnyxCollection) { return filters.in ? filters.in - .map((id) => ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`])) + .map((id) => getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`])) .filter(Boolean) .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; - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); + const allCards = useMemo(() => 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; @@ -418,11 +421,11 @@ function AdvancedSearchFilters() { currentType = CONST.SEARCH.DATA_TYPES.EXPENSE; } - const queryString = useMemo(() => SearchQueryUtils.buildQueryStringFromFilterFormValues(searchAdvancedFilters), [searchAdvancedFilters]); - const queryJSON = useMemo(() => SearchQueryUtils.buildSearchQueryJSON(queryString || SearchQueryUtils.buildCannedSearchQuery()), [queryString]); + const queryString = useMemo(() => buildQueryStringFromFilterFormValues(searchAdvancedFilters), [searchAdvancedFilters]); + const queryJSON = useMemo(() => buildSearchQueryJSON(queryString || buildCannedSearchQuery()), [queryString]); const applyFiltersAndNavigate = () => { - SearchActions.clearAllFilters(); + clearAllFilters(); Navigation.dismissModal(); Navigation.navigate( ROUTES.SEARCH_CENTRAL_PANE.getRoute({ @@ -439,7 +442,7 @@ function AdvancedSearchFilters() { return; } - SearchActions.saveSearch({ + saveSearch({ queryJSON, }); @@ -480,7 +483,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; @@ -508,7 +511,7 @@ function AdvancedSearchFilters() { .filter((filter): filter is NonNullable => !!filter); }) .filter((section) => !!section.length); - const displaySearchButton = queryJSON && !SearchQueryUtils.isCannedSearchQuery(queryJSON); + const displaySearchButton = queryJSON && !isCannedSearchQuery(queryJSON); return ( <> diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 9a6863f478f7..8a231a69163a 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -1,86 +1,305 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} 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 {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; -import CardListItem from '@components/SelectionList/CardListItem'; +import CardListItem from '@components/SelectionList/Search/CardListItem'; +import type {AdditionalCardProps} from '@components/SelectionList/Search/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 {openSearchFiltersCardPage, updateAdvancedFilters} from '@libs/actions/Search'; +import {getBankName, getCardFeedIcon, getDescriptionForPolicyDomainCard, isCard, isCardIssued} from '@libs/CardUtils'; +import {getPolicy} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; -import variables from '@styles/variables'; -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, PersonalDetailsList, WorkspaceCardsList} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type CardFilterItem = Partial & AdditionalCardProps & {isCardFeed?: boolean; correspondingCards?: string[]}; +type ItemsGroupedBySelection = {selected: CardFilterItem[]; unselected: CardFilterItem[]}; + +type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; + +function getRepeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Record) { + const bankFrequency: Record = {}; + for (const key of workspaceCardFeedsKeys) { + // Example: "cards_18755165_Expensify Card" -> "Expensify Card" + const bankName = key.split('_').at(2); + if (bankName) { + bankFrequency[bankName] = (bankFrequency[bankName] || 0) + 1; + } + } + for (const domainFeed of Object.values(domainFeedsData)) { + bankFrequency[domainFeed.bank] = (bankFrequency[domainFeed.bank] || 0) + 1; + } + return Object.keys(bankFrequency).filter((bank) => bankFrequency[bank] > 1); +} + +function createIndividualCardFilterItem(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); + const cardName = card?.nameValuePairs?.cardTitle; + const text = personalDetails?.displayName ?? cardName; + + return { + lastFourPAN: card.lastFourPAN, + isVirtual: card?.nameValuePairs?.isVirtual, + shouldShowOwnersAvatar: true, + cardName, + cardOwnerPersonalDetails: personalDetails ?? undefined, + text, + keyForList: card.cardID.toString(), + isSelected, + bankIcon: { + icon, + }, + isCardFeed: false, + }; +} + +function buildIndividualCardsData( + workspaceCardFeeds: Record, + userCardList: CardList, + personalDetailsList: PersonalDetailsList, + selectedCards: string[], +): ItemsGroupedBySelection { + const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}) + .filter((card) => isCardIssued(card)) + .map((card) => createIndividualCardFilterItem(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] && isCardIssued(card)) + .map((card) => createIndividualCardFilterItem(card, personalDetailsList, selectedCards)); + }); + + const allCardItems = [...userAssignedCards, ...allWorkspaceCards]; + const selectedCardItems: CardFilterItem[] = []; + const unselectedCardItems: CardFilterItem[] = []; + allCardItems.forEach((card) => { + if (card.isSelected) { + selectedCardItems.push(card); + } else { + unselectedCardItems.push(card); + } + }); + return {selected: selectedCardItems, unselected: unselectedCardItems}; +} + +function createCardFeedItem({ + bank, + cardFeedLabel, + keyForList, + correspondingCardIDs, + selectedCards, + translate, +}: { + bank: string; + cardFeedLabel: string | undefined; + keyForList: string; + correspondingCardIDs: string[]; + selectedCards: string[]; + translate: LocaleContextProps['translate']; +}): CardFilterItem { + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : getBankName(bank as CompanyCardFeed); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel}); + const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); + + const icon = getCardFeedIcon(bank as CompanyCardFeed); + return { + text, + keyForList, + isSelected, + shouldShowOwnersAvatar: false, + bankIcon: { + icon, + }, + isCardFeed: true, + correspondingCards: correspondingCardIDs, + }; +} + +function buildCardFeedsData( + workspaceCardFeeds: Record, + domainFeedsData: Record, + selectedCards: string[], + translate: LocaleContextProps['translate'], +): ItemsGroupedBySelection { + const repeatingBanks = getRepeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); + const selectedFeeds: CardFilterItem[] = []; + const unselectedFeeds: CardFilterItem[] = []; + + Object.values(domainFeedsData).forEach((domainFeed) => { + const {domainName, bank, correspondingCardIDs} = domainFeed; + const isBankRepeating = repeatingBanks.includes(bank); + + const feedItem = createCardFeedItem({ + bank, + correspondingCardIDs, + cardFeedLabel: isBankRepeating ? getDescriptionForPolicyDomainCard(domainName) : undefined, + translate, + keyForList: `${domainName}-${bank}`, + selectedCards, + }); + if (feedItem.isSelected) { + selectedFeeds.push(feedItem); + } else { + unselectedFeeds.push(feedItem); + } + }); + + Object.entries(workspaceCardFeeds) + .filter(([, cardFeed]) => !isEmptyObject(cardFeed)) + .forEach(([cardFeedKey, cardFeed]) => { + const cardFeedArray = Object.values(cardFeed ?? {}); + const representativeCard = cardFeedArray.find((cardFeedItem) => isCard(cardFeedItem)); + if (!representativeCard || !cardFeedArray.some((cardFeedItem) => isCard(cardFeedItem) && isCardIssued(cardFeedItem))) { + return; + } + const {domainName, bank} = representativeCard; + const isBankRepeating = repeatingBanks.includes(bank); + 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) && isCardIssued(card)) + .map(([cardKey]) => cardKey); + + const feedItem = createCardFeedItem({ + bank, + correspondingCardIDs, + cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined, + translate, + keyForList: cardFeedKey, + selectedCards, + }); + if (feedItem.isSelected) { + selectedFeeds.push(feedItem); + } else { + unselectedFeeds.push(feedItem); + } + }); + + return {selected: selectedFeeds, unselected: unselectedFeeds}; +} 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 personalDetails = usePersonalDetails(); + + useEffect(() => { + openSearchFiltersCardPage(); + }, []); + + const individualCardsSectionData = useMemo( + () => buildIndividualCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, personalDetails ?? {}, selectedCards), + [workspaceCardFeeds, userCardList, personalDetails, selectedCards], + ); + + 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) && isCardIssued(currentCard)) { + 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, translate), + [domainFeedsData, workspaceCardFeeds, selectedCards, translate], + ); + + const shouldShowSearchInput = + cardFeedsSectionData.selected.length + cardFeedsSectionData.unselected.length + individualCardsSectionData.selected.length + individualCardsSectionData.unselected.length > + CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; + + const searchFunction = useCallback( + (item: CardFilterItem) => + !!item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + !!item.lastFourPAN?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + !!item.cardName?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + (item.isVirtual && translate('workspace.expensifyCard.virtual').toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + [debouncedSearchTerm, translate], + ); 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 = []; + const selectedItems = [...cardFeedsSectionData.selected, ...individualCardsSectionData.selected]; + newSections.push({ title: undefined, - data: cards, - shouldShow: cards.length > 0, + data: selectedItems.filter(searchFunction), + shouldShow: selectedItems.length > 0, + }); + newSections.push({ + title: translate('search.filters.card.cardFeeds'), + data: cardFeedsSectionData.unselected.filter(searchFunction), + shouldShow: cardFeedsSectionData.unselected.length > 0, + }); + newSections.push({ + title: translate('search.filters.card.individualCards'), + data: individualCardsSectionData.unselected.filter(searchFunction), + shouldShow: individualCardsSectionData.unselected.length > 0, }); return newSections; - }, [cardList, styles, newCards]); + }, [cardFeedsSectionData.selected, cardFeedsSectionData.unselected, individualCardsSectionData.selected, individualCardsSectionData.unselected, searchFunction, translate]); const handleConfirmSelection = useCallback(() => { - SearchActions.updateAdvancedFilters({ - cardID: newCards, + updateAdvancedFilters({ + 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 headerMessage = debouncedSearchTerm.trim() && sections.every((section) => !section.data.length) ? translate('common.noResultsFound') : ''; + const footerContent = useMemo( () => (