Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"Bushwick",
"BYOC",
"capitalone",
"capitalonecards",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we could skip it in place, I can do it in a follow up

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"cardreader",
"commentbubbles",
"creditcards",
Expand Down
7 changes: 5 additions & 2 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {feedKeysWithAssignedCardsSelector} from '@selectors/Card';
import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
Expand Down Expand Up @@ -189,6 +190,7 @@ function SearchAutocompleteList({
const {shouldUseNarrowLayout} = useResponsiveLayout();

const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true});
const [feedKeysWithCards] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {selector: feedKeysWithAssignedCardsSelector, canBeMissing: true});
const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true});
const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true});
const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES, {canBeMissing: true});
Expand Down Expand Up @@ -304,8 +306,8 @@ function SearchAutocompleteList({
const feedAutoCompleteList = useMemo(() => {
// We don't want to show the "Expensify Card" feeds in the autocomplete suggestion list as they don't have real "Statements"
// Thus passing an empty object to the `allCards` parameter.
return Object.values(getCardFeedsForDisplay(allFeeds, {}, translate));
}, [allFeeds, translate]);
return Object.values(getCardFeedsForDisplay(allFeeds, {}, translate, feedKeysWithCards));
}, [allFeeds, translate, feedKeysWithCards]);

const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {canBeMissing: false});
const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, {canBeMissing: true});
Expand Down Expand Up @@ -701,6 +703,7 @@ function SearchAutocompleteList({
currentUserAccountID,
autoCompleteWithSpace: false,
translate,
feedKeysWithCards,
})
: query,
singleIcon: expensifyIcons.History,
Expand Down
6 changes: 4 additions & 2 deletions src/components/Search/SearchPageHeader/SearchFiltersBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {isUserValidatedSelector} from '@selectors/Account';
import {feedKeysWithAssignedCardsSelector} from '@selectors/Card';
import {emailSelector} from '@selectors/Session';
import React, {useCallback, useContext, useMemo, useRef} from 'react';
import type {ReactNode} from 'react';
Expand Down Expand Up @@ -120,6 +121,7 @@ function SearchFiltersBar({
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true});
const [hasMultipleOutputCurrency] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasMultipleOutputCurrenciesSelector, canBeMissing: true});
const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true});
const [feedKeysWithCards] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {selector: feedKeysWithAssignedCardsSelector, canBeMissing: true});
const {isAccountLocked, showLockedAccountModal} = useContext(LockedAccountContext);
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Filter', 'Columns']);
const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext);
Expand Down Expand Up @@ -209,10 +211,10 @@ function SearchFiltersBar({

const [feedOptions, feed] = useMemo(() => {
const feedFilterValues = flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED)?.filters?.map((filter) => filter.value);
const options = getFeedOptions(allFeeds, nonPersonalAndWorkspaceCards, translate);
const options = getFeedOptions(allFeeds, nonPersonalAndWorkspaceCards, translate, feedKeysWithCards);
const value = feedFilterValues ? options.filter((option) => feedFilterValues.includes(option.value)) : [];
return [options, value];
}, [flatFilters, allFeeds, nonPersonalAndWorkspaceCards, translate]);
}, [flatFilters, allFeeds, nonPersonalAndWorkspaceCards, translate, feedKeysWithCards]);

const [statusOptions, status] = useMemo(() => {
const options = type ? getStatusOptions(translate, type.value) : [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useIsFocused} from '@react-navigation/native';
import {feedKeysWithAssignedCardsSelector} from '@selectors/Card';
import {accountIDSelector} from '@selectors/Session';
import {deepEqual} from 'fast-equals';
import isEmpty from 'lodash/isEmpty';
Expand Down Expand Up @@ -66,6 +67,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
const taxRates = useMemo(() => getAllTaxRates(policies), [policies]);
const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {canBeMissing: true});
const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true});
const [feedKeysWithCards] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {selector: feedKeysWithAssignedCardsSelector, canBeMissing: true});
const {inputQuery: originalInputQuery} = queryJSON;
const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false});
const queryText = buildUserReadableQueryString({
Expand All @@ -79,6 +81,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
currentUserAccountID,
autoCompleteWithSpace: true,
translate,
feedKeysWithCards,
});

const [searchContext] = useOnyx(ONYXKEYS.SEARCH_CONTEXT, {canBeMissing: true});
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useCardFeeds.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {feedKeysWithAssignedCardsSelector} from '@selectors/Card';
import type {OnyxCollection, ResultMetadata} from 'react-native-onyx';
import {getCombinedCardFeedsFromAllFeeds, getWorkspaceCardFeedsStatus} from '@libs/CardFeedUtils';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -22,13 +23,14 @@ import useWorkspaceAccountID from './useWorkspaceAccountID';
const useCardFeeds = (policyID: string | undefined): [CombinedCardFeeds | undefined, ResultMetadata<OnyxCollection<CardFeeds>>, CardFeeds | undefined, CardFeedsStatusByDomainID] => {
const workspaceAccountID = useWorkspaceAccountID(policyID);
const [allFeeds, allFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true});
const [feedKeysWithCards] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {selector: feedKeysWithAssignedCardsSelector, canBeMissing: true});
const defaultFeed = allFeeds?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`];

let workspaceFeeds: CombinedCardFeeds | undefined;
if (policyID && allFeeds) {
const shouldIncludeFeedPredicate = (combinedCardFeed: CombinedCardFeed) =>
combinedCardFeed.preferredPolicy ? combinedCardFeed.preferredPolicy === policyID : combinedCardFeed.domainID === workspaceAccountID;
workspaceFeeds = getCombinedCardFeedsFromAllFeeds(allFeeds, shouldIncludeFeedPredicate);
workspaceFeeds = getCombinedCardFeedsFromAllFeeds(allFeeds, shouldIncludeFeedPredicate, feedKeysWithCards);
}

const workspaceCardFeedsStatus = getWorkspaceCardFeedsStatus(allFeeds);
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useCardFeedsForDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {feedKeysWithAssignedCardsSelector} from '@selectors/Card';
import type {OnyxCollection} from 'react-native-onyx';
import {getCardFeedsForDisplayPerPolicy} from '@libs/CardFeedUtils';
import {isCustomFeed} from '@libs/CardUtils';
Expand All @@ -20,13 +21,14 @@ const eligiblePoliciesSelector = (policies: OnyxCollection<Policy>): string[] =>
const useCardFeedsForDisplay = () => {
const {localeCompare, translate} = useLocalize();
const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true});
const [feedKeysWithCards] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {selector: feedKeysWithAssignedCardsSelector, canBeMissing: true});
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true});
const [eligiblePoliciesIDsArray] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {
selector: eligiblePoliciesSelector,
canBeMissing: true,
});

const cardFeedsByPolicy = getCardFeedsForDisplayPerPolicy(allFeeds, translate);
const cardFeedsByPolicy = getCardFeedsForDisplayPerPolicy(allFeeds, translate, feedKeysWithCards);
const eligiblePoliciesIDs = new Set(eligiblePoliciesIDsArray);

let defaultCardFeed;
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/useSearchTypeMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {feedKeysWithAssignedCardsSelector} from '@selectors/Card';
import {accountIDSelector} from '@selectors/Session';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import type {OnyxCollection} from 'react-native-onyx';
Expand Down Expand Up @@ -68,6 +69,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) {
const [isPopoverVisible, setIsPopoverVisible] = useState(false);

const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true});
const [feedKeysWithCards] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {selector: feedKeysWithAssignedCardsSelector, canBeMissing: true});
const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]);

useSuggestedSearchDefaultNavigation({
Expand Down Expand Up @@ -121,6 +123,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) {
currentUserAccountID,
autoCompleteWithSpace: false,
translate,
feedKeysWithCards,
});
}

Expand Down Expand Up @@ -170,6 +173,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) {
taxRates,
nonPersonalAndWorkspaceCards,
allFeeds,
feedKeysWithCards,
allPolicies,
currentUserAccountID,
translate,
Expand Down
39 changes: 34 additions & 5 deletions src/libs/CardFeedUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {FeedKeysWithAssignedCards} from '@selectors/Card';
import type {OnyxCollection} from 'react-native-onyx';
import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider';
import type {AdditionalCardProps} from '@components/SelectionListWithSections/Search/CardListItem';
Expand All @@ -9,6 +10,7 @@ import type {Card, CardFeeds, CardList, PersonalDetailsList, Policy, WorkspaceCa
import type {CardFeedsStatus, CardFeedsStatusByDomainID, CardFeedWithNumber, CombinedCardFeed} from '@src/types/onyx/CardFeeds';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import {
feedHasCards,
getBankName,
getCardFeedIcon,
getCardFeedWithDomainID,
Expand All @@ -19,6 +21,8 @@ import {
isCard,
isCardClosed,
isCardHiddenFromSearch,
isCustomFeed,
isDirectFeed,
} from './CardUtils';
import type {CompanyCardFeedIcons} from './CardUtils';
import {getDescriptionForPolicyDomainCard} from './PolicyUtils';
Expand Down Expand Up @@ -435,7 +439,12 @@ const generateSelectedCards = (
*
* The `allCards` parameter is only used to determine if we should add the "Expensify Card" feeds.
*/
function getCardFeedsForDisplay(allCardFeeds: OnyxCollection<CardFeeds>, allCards: CardList | undefined, translate: LocalizedTranslate): CardFeedsForDisplay {
function getCardFeedsForDisplay(
allCardFeeds: OnyxCollection<CardFeeds>,
allCards: CardList | undefined,
translate: LocalizedTranslate,
feedKeysWithCards?: FeedKeysWithAssignedCards,
): CardFeedsForDisplay {
const cardFeedsForDisplay = {} as CardFeedsForDisplay;

for (const [domainKey, cardFeeds] of Object.entries(allCardFeeds ?? {})) {
Expand All @@ -445,7 +454,7 @@ function getCardFeedsForDisplay(allCardFeeds: OnyxCollection<CardFeeds>, allCard
continue;
}

for (const key of Object.keys(getOriginalCompanyFeeds(cardFeeds))) {
for (const key of Object.keys(getOriginalCompanyFeeds(cardFeeds, feedKeysWithCards, Number(fundID)))) {
const feed = key as CardFeedWithNumber;
const id = `${fundID}_${feed}`;

Expand Down Expand Up @@ -489,7 +498,11 @@ function getCardFeedsForDisplay(allCardFeeds: OnyxCollection<CardFeeds>, allCard
*
* Note: "Expensify Card" feeds are not included.
*/
function getCardFeedsForDisplayPerPolicy(allCardFeeds: OnyxCollection<CardFeeds>, translate: LocalizedTranslate): Record<string, CardFeedForDisplay[]> {
function getCardFeedsForDisplayPerPolicy(
allCardFeeds: OnyxCollection<CardFeeds>,
translate: LocalizedTranslate,
feedKeysWithCards?: FeedKeysWithAssignedCards,
): Record<string, CardFeedForDisplay[]> {
const cardFeedsForDisplayPerPolicy = {} as Record<string, CardFeedForDisplay[]>;

for (const [domainKey, cardFeeds] of Object.entries(allCardFeeds ?? {})) {
Expand All @@ -499,7 +512,7 @@ function getCardFeedsForDisplayPerPolicy(allCardFeeds: OnyxCollection<CardFeeds>
continue;
}

for (const [key, feedData] of Object.entries(getOriginalCompanyFeeds(cardFeeds))) {
for (const [key, feedData] of Object.entries(getOriginalCompanyFeeds(cardFeeds, feedKeysWithCards, Number(fundID)))) {
const preferredPolicy = feedData && 'preferredPolicy' in feedData ? (feedData.preferredPolicy ?? '') : '';
const feed = key as CardFeedWithNumber;
const id = `${fundID}_${feed}`;
Expand Down Expand Up @@ -531,7 +544,11 @@ function getWorkspaceCardFeedsStatus(allFeeds: OnyxCollection<CardFeeds> | undef
}, {} as CardFeedsStatusByDomainID);
}

function getCombinedCardFeedsFromAllFeeds(allFeeds: OnyxCollection<CardFeeds> | undefined, includeFeedPredicate?: (feed: CombinedCardFeed) => boolean): CombinedCardFeeds {
function getCombinedCardFeedsFromAllFeeds(
allFeeds: OnyxCollection<CardFeeds> | undefined,
includeFeedPredicate?: (feed: CombinedCardFeed) => boolean,
feedKeysWithCards?: FeedKeysWithAssignedCards,
): CombinedCardFeeds {
return Object.entries(allFeeds ?? {}).reduce<CombinedCardFeeds>((acc, [onyxKey, feeds]) => {
const domainID = Number(onyxKey.split('_').at(-1));

Expand All @@ -552,6 +569,18 @@ function getCombinedCardFeedsFromAllFeeds(allFeeds: OnyxCollection<CardFeeds> |
continue;
}

// When we have card data, filter out stale feeds:
// - Direct feeds without oAuthAccountDetails AND no assigned cards
// - "Gray zone" feeds (not commercial, not direct) without assigned cards
if (feedKeysWithCards) {
if (isDirectFeed(feedName) && !oAuthAccountDetails && !feedHasCards(feedName, domainID, feedKeysWithCards)) {
continue;
}
if (!isCustomFeed(feedName) && !isDirectFeed(feedName) && !feedHasCards(feedName, domainID, feedKeysWithCards)) {
continue;
}
}

const combinedCardFeed: CombinedCardFeed = {
...feedSettings,
...oAuthAccountDetails,
Expand Down
65 changes: 62 additions & 3 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {FeedKeysWithAssignedCards} from '@selectors/Card';
import {fromUnixTime, isBefore} from 'date-fns';
import groupBy from 'lodash/groupBy';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
Expand Down Expand Up @@ -448,7 +449,7 @@ function getCardFeedIcon(cardFeed: CardFeedWithNumber | CardFeedWithDomainID | u
/**
* Verify if the feed is a custom feed. Those are also referred to as commercial feeds.
*/
function isCustomFeed(feed: CardFeedWithNumber | CardFeedWithDomainID | undefined): boolean {
function isCustomFeed(feed: string | undefined): boolean {
if (!feed) {
return false;
}
Expand All @@ -465,13 +466,69 @@ function isCSVFeedOrExpensifyCard(feedKey: string): boolean {
return lowerFeedKey.startsWith('csv') || lowerFeedKey.includes(CONST.COMPANY_CARD.FEED_BANK_NAME.CSV) || feedKey === CONST.EXPENSIFY_CARD.BANK;
}

function getOriginalCompanyFeeds(cardFeeds: OnyxEntry<CardFeeds>): CompanyFeeds {
/**
* Checks if a feed is a direct feed (OAuth or Plaid based).
* Mirrors the backend's Card::isOAuthBank and Card::isPlaidBank logic.
*/
function isDirectFeed(feed: string | undefined): boolean {
if (!feed) {
return false;
}
const lowerFeed = feed.toLowerCase();
return lowerFeed.startsWith('oauth') || lowerFeed.startsWith('plaid');
}

/**
* Checks whether a feed has any assigned cards using a precomputed lightweight map.
* This is used as a validity signal: direct feeds use it as a fallback when oAuthAccountDetails is missing,
* and "gray zone" feeds (neither commercial nor direct) use it as the sole visibility criterion.
* The feedKeysWithCards map is produced by feedKeysWithAssignedCardsSelector in selectors/Card.ts.
*/
function feedHasCards(feedName: string, domainID: number, feedKeysWithCards: FeedKeysWithAssignedCards | undefined): boolean {
if (!feedKeysWithCards || !domainID) {
return false;
}

return feedKeysWithCards[`${domainID}_${feedName}`] === true;
}

/**
* Returns company feeds from cardFeeds, filtering out pending/deleted feeds, Expensify Card, stale direct feeds,
* and "gray zone" feeds with no assigned cards.
*
* Feed visibility rules (when feedKeysWithCards is provided):
* - Commercial feeds (isCustomFeed): always shown
* - Direct feeds (isDirectFeed): shown if they have oAuthAccountDetails OR assigned cards
* - Gray zone feeds (everything else): shown only if they have assigned cards
*/
function getOriginalCompanyFeeds(cardFeeds: OnyxEntry<CardFeeds>, feedKeysWithCards?: FeedKeysWithAssignedCards, domainID?: number): CompanyFeeds {
const oAuthAccountDetails = cardFeeds?.settings?.oAuthAccountDetails;
const resolvedDomainID = domainID ?? CONST.DEFAULT_NUMBER_ID;
return Object.fromEntries(
Object.entries(cardFeeds?.settings?.companyCards ?? {}).filter(([key, value]) => {
if (value?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || value?.pending) {
return false;
}
return key !== CONST.EXPENSIFY_CARD.BANK;
if (key === CONST.EXPENSIFY_CARD.BANK) {
return false;
}

// When we don't have card data, we can't make informed filtering decisions - show all feeds.
if (!feedKeysWithCards) {
return true;
}

// A direct feed is stale if it has no oAuthAccountDetails AND no assigned cards.
if (isDirectFeed(key) && !oAuthAccountDetails?.[key as CompanyCardFeed] && !feedHasCards(key, resolvedDomainID, feedKeysWithCards)) {
return false;
}

// "Gray zone" feeds (not commercial AND not direct) are only shown when they have assigned cards.
if (!isCustomFeed(key) && !isDirectFeed(key) && !feedHasCards(key, resolvedDomainID, feedKeysWithCards)) {
return false;
}

return true;
}),
);
}
Expand Down Expand Up @@ -1165,6 +1222,8 @@ export {
getPlaidInstitutionId,
getFeedConnectionBrokenCard,
getCorrectStepForPlaidSelectedBank,
isDirectFeed,
feedHasCards,
getOriginalCompanyFeeds,
getCompanyCardFeed,
getCardFeedWithDomainID,
Expand Down
Loading
Loading