From 54f2c0bce88ac9a9eb8e04a07815a96004c37768 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:08:55 +0200 Subject: [PATCH 1/3] Replace CARD_FEED_ERRORS derived value with a memoized useOnyxState hook --- __mocks__/react-native-onyx.ts | 4 +- src/ONYXKEYS.ts | 2 - .../WorkspaceRowBrickRoadIndicator.tsx | 6 +- src/hooks/useCardFeedErrors.ts | 46 ++- .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 - .../OnyxDerived/configs/cardFeedErrors.ts | 248 ---------------- .../home/YourSpendSection/useYourSpendData.ts | 2 +- src/selectors/CardFeedErrors.ts | 279 ++++++++++++++++++ src/types/onyx/DerivedValues.ts | 6 - src/types/onyx/index.ts | 2 - ...eTest.ts => CardFeedErrorsSelectorTest.ts} | 137 ++++++--- tests/unit/CompanyCardsRBRTest.ts | 39 ++- .../YourSpendSection/useYourSpendDataTest.ts | 18 +- tests/unit/hooks/useAssignCard.test.ts | 22 +- tests/unit/useCardFeedErrorsTest.ts | 105 +++++++ 15 files changed, 545 insertions(+), 373 deletions(-) delete mode 100644 src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts create mode 100644 src/selectors/CardFeedErrors.ts rename tests/unit/{CardFeedErrorsDerivedValueTest.ts => CardFeedErrorsSelectorTest.ts} (87%) create mode 100644 tests/unit/useCardFeedErrorsTest.ts diff --git a/__mocks__/react-native-onyx.ts b/__mocks__/react-native-onyx.ts index 6db22ac1b627..f5b09f2949da 100644 --- a/__mocks__/react-native-onyx.ts +++ b/__mocks__/react-native-onyx.ts @@ -5,7 +5,7 @@ import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; // eslint-disable-next-line no-restricted-imports -import Onyx, {useOnyx} from 'react-native-onyx'; +import Onyx, {useOnyx, useOnyxState} from 'react-native-onyx'; let connectCallbackDelay = 0; function addDelayToConnectCallback(delay: number) { @@ -41,4 +41,4 @@ const reactNativeOnyxMock: ReactNativeOnyxMock = { export default reactNativeOnyxMock; -export {useOnyx}; +export {useOnyx, useOnyxState}; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2b16572d106..9c493e0ab748 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1408,7 +1408,6 @@ const ONYXKEYS = { VISIBLE_REPORT_ACTIONS: 'visibleReportActions', NON_PERSONAL_AND_WORKSPACE_CARD_LIST: 'nonPersonalAndWorkspaceCardList', PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', - CARD_FEED_ERRORS: 'cardFeedErrors', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap', GUIDE_ACCOUNT_IDS: 'guideAccountIDs', @@ -1979,7 +1978,6 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: OnyxTypes.VisibleReportActionsDerivedValue; [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.NonPersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue; - [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue; [ONYXKEYS.DERIVED.SPEND_DATA_SIGNATURE]: OnyxTypes.SpendDataSignatureDerivedValue; diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx index a7a78ad6d77c..f4bce40711a9 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx @@ -1,5 +1,6 @@ import Icon from '@components/Icon'; +import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; @@ -31,8 +32,7 @@ type WorkspaceRowBrickRoadIndicatorProps = { policyID: string; }; -const createCardFeedErrorsSelector = (workspaceAccountID: number) => (cardFeedErrors: OnyxEntry) => - !!cardFeedErrors?.shouldShowRbrForWorkspaceAccountID?.[workspaceAccountID]; +const createCardFeedErrorsSelector = (workspaceAccountID: number) => (cardFeedErrors: CardFeedErrors) => !!cardFeedErrors.shouldShowRbrForWorkspaceAccountID[workspaceAccountID]; const createPolicyErrorsSelector = (connectionSyncProgress: OnyxEntry) => (policy: OnyxEntry) => getUberConnectionErrorDirectlyFromPolicy(policy) || @@ -50,7 +50,7 @@ function WorkspaceRowBrickRoadIndicator({policyID}: WorkspaceRowBrickRoadIndicat const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']); const workspaceAccountID = useWorkspaceAccountID(policyID); const [hasReimbursementAccountErrors] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {selector: hasReimbursementAccountErrorsSelector}); - const [hasCardFeedErrors] = useOnyx(ONYXKEYS.DERIVED.CARD_FEED_ERRORS, {selector: createCardFeedErrorsSelector(workspaceAccountID)}); + const hasCardFeedErrors = useCardFeedErrors(createCardFeedErrorsSelector(workspaceAccountID)); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const [hasPolicyErrors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: createPolicyErrorsSelector(connectionSyncProgress)}); const [isHRCompleteSetupNeeded] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isMergeHRCompleteSetupNeededSelector}); diff --git a/src/hooks/useCardFeedErrors.ts b/src/hooks/useCardFeedErrors.ts index 9a7a3b6a85ca..7feaaeb0fb88 100644 --- a/src/hooks/useCardFeedErrors.ts +++ b/src/hooks/useCardFeedErrors.ts @@ -1,33 +1,29 @@ import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeedErrors, CardFeedErrorState} from '@src/types/onyx/DerivedValues'; +import type {CardFeeds, WorkspaceCardsList} from '@src/types/onyx'; +import type {CardFeedErrors} from '@src/types/onyx/DerivedValues'; -import useOnyx from './useOnyx'; +import type {OnyxCollection} from 'react-native-onyx'; -const DEFAULT_CARD_FEED_ERROR_STATE: CardFeedErrorState = { - shouldShowRBR: false, - isFeedConnectionBroken: false, - shouldPromptBrokenConnection: false, - hasFeedErrors: false, - hasWorkspaceErrors: false, - hasFeedConnectionIssue: false, -}; +import {getCardFeedErrors} from '@selectors/CardFeedErrors'; +import {useOnyxState} from 'react-native-onyx'; -const DEFAULT_CARD_FEED_ERRORS: CardFeedErrors = { - cardFeedErrors: {}, - cardsWithBrokenFeedConnection: {}, - personalCardsWithBrokenConnection: {}, - shouldShowRbrForWorkspaceAccountID: {}, - shouldShowRbrForFeedNameWithDomainID: {}, - all: DEFAULT_CARD_FEED_ERROR_STATE, - companyCards: DEFAULT_CARD_FEED_ERROR_STATE, - expensifyCard: DEFAULT_CARD_FEED_ERROR_STATE, - personalCard: DEFAULT_CARD_FEED_ERROR_STATE, -}; +const CARD_FEED_ERRORS_DEPENDENCIES = [ONYXKEYS.CARD_LIST, ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, ONYXKEYS.CURRENT_DATE]; -function useCardFeedErrors(): CardFeedErrors { - const [cardFeedErrors] = useOnyx(ONYXKEYS.DERIVED.CARD_FEED_ERRORS); - - return cardFeedErrors ?? DEFAULT_CARD_FEED_ERRORS; +function useCardFeedErrors(): CardFeedErrors; +function useCardFeedErrors(selector: (cardFeedErrors: CardFeedErrors) => TSelected): TSelected; +function useCardFeedErrors(selector?: (cardFeedErrors: CardFeedErrors) => TSelected): CardFeedErrors | TSelected { + return useOnyxState( + (state) => { + // useOnyxState's state view types a collection key as one member, but at runtime it returns the whole collection + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + const allWorkspaceCards = state[ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST] as OnyxCollection; + const cardFeeds = state[ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER] as OnyxCollection; + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + const cardFeedErrors = getCardFeedErrors(state[ONYXKEYS.CARD_LIST], allWorkspaceCards, cardFeeds, state[ONYXKEYS.CURRENT_DATE]); + return selector ? selector(cardFeedErrors) : cardFeedErrors; + }, + {dependencies: CARD_FEED_ERRORS_DEPENDENCIES}, + ); } export default useCardFeedErrors; diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 4ea4fabd9560..9a7e58df26e3 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -4,7 +4,6 @@ import type {ValueOf} from 'type-fest'; import type {OnyxDerivedValueConfig} from './types'; -import cardFeedErrorsConfig from './configs/cardFeedErrors'; import guideAccountIDsConfig from './configs/guideAccountIDs'; import loginToAccountIDMapConfig from './configs/loginToAccountIDMap'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; @@ -28,7 +27,6 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS]: visibleReportActionsConfig, [ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST]: nonPersonalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig, - [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: loginToAccountIDMapConfig, [ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS]: guideAccountIDsConfig, diff --git a/src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts b/src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts deleted file mode 100644 index 787ee47c404e..000000000000 --- a/src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts +++ /dev/null @@ -1,248 +0,0 @@ -import {getCombinedCardFeedsFromAllFeeds, getWorkspaceCardFeedsStatus} from '@libs/CardFeedUtils'; -import { - filterAllInactiveCards, - forEachAssignedCard, - getCardFeedWithDomainID, - isBrokenConnectionPastDismissThreshold, - hasCardConnectionIssue, - isCardConnectionBroken, - isLastScrapePastDismissThreshold, - isPersonalCard, -} from '@libs/CardUtils'; - -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card} from '@src/types/onyx'; -import type {CardErrors, CardFeedErrorsObject, CardFeedErrorState} from '@src/types/onyx/DerivedValues'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -import {buildFeedKeysWithAssignedCards} from '@selectors/Card'; - -const DEFAULT_CARD_FEED_ERROR_STATE: CardFeedErrorState = { - shouldShowRBR: false, - hasFeedErrors: false, - hasWorkspaceErrors: false, - isFeedConnectionBroken: false, - shouldPromptBrokenConnection: false, - hasFeedConnectionIssue: false, -}; - -function getShouldShowRBR(state: Partial): boolean { - if (state.hasFeedErrors) { - return true; - } - if (state.hasWorkspaceErrors) { - return true; - } - - // Deliberately keyed on the prompting flag, not `isFeedConnectionBroken`: past the grace period we stop showing - // the RBR while the connection stays broken so it can still be fixed. - return !!state.shouldPromptBrokenConnection; -} - -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.CARD_FEED_ERRORS, - // CURRENT_DATE (day-granularity) is a trigger-only dependency: it makes this value recompute when the day rolls over so the - // 90-day broken-connection grace period (isBrokenConnectionPastDismissThreshold) is re-evaluated even if no card/feed data changes. - dependencies: [ONYXKEYS.CARD_LIST, ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, ONYXKEYS.CURRENT_DATE], - compute: ([globalCardList, allWorkspaceCards, cardFeeds]) => { - const feedKeysWithCards = buildFeedKeysWithAssignedCards(allWorkspaceCards); - const combinedCompanyCardFeeds = getCombinedCardFeedsFromAllFeeds(cardFeeds, undefined, feedKeysWithCards); - const workspaceCardFeedsStatus = getWorkspaceCardFeedsStatus(cardFeeds); - - const cardFeedErrors: CardFeedErrorsObject = {}; - const shouldShowRbrForWorkspaceAccountID: Record = {}; - const shouldShowRbrForFeedNameWithDomainID: Record = {}; - - const allFeedsState: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; - const companyCardFeedsState: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; - const expensifyCardFeedStates: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; - const personalCardStates: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; - - const cardsWithBrokenFeedConnection: Record = {}; - const personalCardsWithBrokenConnection: Record = {}; - - function addErrorsForPersonalCard(card: Card) { - // Once the card has gone without a successful sync past the grace period we stop leading the user to it: the - // time-sensitive task and the RBR are removed. The connection error is a server-set `card.errors` entry, which - // is what lights the Account button via `hasPaymentMethodError`, so past the threshold it must not light the - // RBR. This is keyed on the last successful sync rather than `isCardConnectionBroken`, because the server sets - // the connection error even for scrape statuses that check ignores (e.g. 434). `errorFields` entries are left - // alone: they are written by a user-initiated action that failed (a manual sync, a reimbursable/start-date - // update), so they stay actionable no matter how old the connection is. The error itself stays on the card. - const isPastDismissThreshold = isLastScrapePastDismissThreshold(card); - const hasCardErrors = (!isPastDismissThreshold && !isEmptyObject(card.errors)) || !isEmptyObject(card.errorFields); - const cardErrors = { - ...(hasCardErrors - ? { - [card.cardID]: { - errors: card.errors, - errorFields: card.errorFields, - pendingAction: card.pendingAction, - }, - } - : {}), - } as Record; - - const isFeedConnectionBroken = hasCardConnectionIssue(card) && !isPastDismissThreshold; - // Track personal cards with broken feed connection - if (isFeedConnectionBroken) { - personalCardsWithBrokenConnection[card.cardID] = card; - } - const newFeedState: Omit = { - isFeedConnectionBroken, - // A personal card is fixed from its own details page, which reads the card directly, so there is no - // separate capability signal to preserve here. Prompting follows the same grace period. - shouldPromptBrokenConnection: isFeedConnectionBroken, - hasFeedErrors: !isEmptyObject(cardErrors), - hasWorkspaceErrors: false, - hasFeedConnectionIssue: hasCardConnectionIssue(card), - }; - const shouldShowRBR = getShouldShowRBR(newFeedState); - - personalCardStates.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken; - personalCardStates.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection; - personalCardStates.hasFeedErrors ||= newFeedState.hasFeedErrors; - personalCardStates.shouldShowRBR ||= shouldShowRBR; - } - - function addErrorsForCard(card: Card) { - const bankName = card.bank; - const workspaceAccountID = Number(card.fundID); - - const isExpensifyCard = bankName === CONST.EXPENSIFY_CARD.BANK; - - if (Number.isNaN(workspaceAccountID)) { - return; - } - - const feedNameWithDomainID = getCardFeedWithDomainID(bankName, workspaceAccountID); - const previousFeedErrors = cardFeedErrors[feedNameWithDomainID] ?? DEFAULT_CARD_FEED_ERROR_STATE; - - const feed = combinedCompanyCardFeeds?.[feedNameWithDomainID]; - const feedErrors = { - ...previousFeedErrors.feedErrors, - ...feed?.errors, - } as Errors; - const hasFeedErrors = feedNameWithDomainID ? !isEmptyObject(feedErrors) : false; - - const workspaceErrors = workspaceCardFeedsStatus?.[workspaceAccountID]?.errors; - const hasWorkspaceErrors = !!workspaceErrors; - - const hasCardErrors = !isEmptyObject(card.errors) || !isEmptyObject(card.errorFields) || card.pendingAction; - const cardErrors = { - ...previousFeedErrors.cardErrors, - ...(cardFeedErrors[feedNameWithDomainID]?.cardErrors ?? {}), - ...(hasCardErrors - ? { - [card.cardID]: { - errors: card.errors, - errorFields: card.errorFields, - pendingAction: card.pendingAction, - }, - } - : {}), - } as Record; - - // Keep the broken state itself truthful: the Company cards page renders its "log into your bank" fix from - // this flag, and the reconnect needs it to clear the error afterwards. Only stop *prompting* (the RBR and - // the time-sensitive task) once the connection has been unresolved past the grace period. - const isFeedConnectionBroken = isCardConnectionBroken(card); - const shouldPromptBrokenConnection = isFeedConnectionBroken && !isBrokenConnectionPastDismissThreshold(card); - - const newFeedState: Omit = { - isFeedConnectionBroken: isFeedConnectionBroken || previousFeedErrors.isFeedConnectionBroken, - shouldPromptBrokenConnection: shouldPromptBrokenConnection || previousFeedErrors.shouldPromptBrokenConnection, - hasFeedErrors: hasFeedErrors || previousFeedErrors.hasFeedErrors, - hasWorkspaceErrors: hasWorkspaceErrors || previousFeedErrors.hasWorkspaceErrors, - hasFeedConnectionIssue: hasCardConnectionIssue(card) || previousFeedErrors.hasFeedConnectionIssue, - }; - - const shouldShowRBR = getShouldShowRBR(newFeedState) || previousFeedErrors.shouldShowRBR; - - cardFeedErrors[feedNameWithDomainID] = { - ...newFeedState, - shouldShowRBR, - cardErrors, - feedErrors, - workspaceErrors, - }; - - // Track cards with broken feed connection. This stays truthful past the grace period so that reconnecting - // still clears the error (see useUpdateFeedBrokenConnection); consumers that prompt the user filter on the - // grace period themselves. - if (isFeedConnectionBroken) { - cardsWithBrokenFeedConnection[card.cardID] = card; - } - - // Update aggregate states - once true, always stays true - const cardTypeState = isExpensifyCard ? expensifyCardFeedStates : companyCardFeedsState; - - allFeedsState.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken; - allFeedsState.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection; - allFeedsState.hasFeedErrors ||= newFeedState.hasFeedErrors; - allFeedsState.hasWorkspaceErrors ||= newFeedState.hasWorkspaceErrors; - allFeedsState.hasFeedConnectionIssue ||= newFeedState.hasFeedConnectionIssue; - - cardTypeState.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken; - cardTypeState.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection; - cardTypeState.hasFeedErrors ||= newFeedState.hasFeedErrors; - cardTypeState.hasWorkspaceErrors ||= newFeedState.hasWorkspaceErrors; - cardTypeState.hasFeedConnectionIssue ||= newFeedState.hasFeedConnectionIssue; - - shouldShowRbrForWorkspaceAccountID[workspaceAccountID] ||= shouldShowRBR; - shouldShowRbrForFeedNameWithDomainID[feedNameWithDomainID] ||= shouldShowRBR; - } - - for (const card of Object.values(globalCardList ?? {})) { - const isPersonal = isPersonalCard(card); - if (isPersonal) { - addErrorsForPersonalCard(card); - } else { - addErrorsForCard(card); - } - } - - const workspaceCards = allWorkspaceCards ?? {}; - for (const key of Object.keys(workspaceCards)) { - const keyParts = key.split(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); - const workspaceCardParamParts = keyParts.at(1)?.split('_'); - - if (!workspaceCardParamParts) { - continue; - } - - forEachAssignedCard(filterAllInactiveCards(workspaceCards[key]), addErrorsForCard); - } - - allFeedsState.shouldShowRBR = getShouldShowRBR(allFeedsState); - companyCardFeedsState.shouldShowRBR = getShouldShowRBR(companyCardFeedsState); - expensifyCardFeedStates.shouldShowRBR = getShouldShowRBR(expensifyCardFeedStates); - - return { - // The errors of all card feeds. - cardFeedErrors, - cardsWithBrokenFeedConnection, - personalCardsWithBrokenConnection, - - // Mappings of whether to show the RBR for each workspace account ID and per feed name with domain ID - shouldShowRbrForWorkspaceAccountID, - shouldShowRbrForFeedNameWithDomainID, - - // Whether any of the feeds have one of the below errors - all: allFeedsState, - - // Whether any of the company cards have one of the below errors - companyCards: companyCardFeedsState, - - // Whether any of the expensify cards have one of the below errors - expensifyCard: expensifyCardFeedStates, - - // Whether any of the personal cards have one of the below errors - personalCard: personalCardStates, - }; - }, -}); diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 8aa681848b6c..b917780c1db0 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -417,7 +417,7 @@ function useYourSpendData(): UseYourSpendDataReturn { ); // Destructure here so downstream memos depend only on the sub-records, not on - // the parent value that's rebuilt on every CARD_FEED_ERRORS tick. + // the parent value that's rebuilt whenever a card input changes. const {cardsWithBrokenFeedConnection, personalCardsWithBrokenConnection} = useCardFeedErrors(); // Memo anchor: the compiler does not auto-cache these calls, so downstream diff --git a/src/selectors/CardFeedErrors.ts b/src/selectors/CardFeedErrors.ts new file mode 100644 index 000000000000..a7a62731ffc5 --- /dev/null +++ b/src/selectors/CardFeedErrors.ts @@ -0,0 +1,279 @@ +import {getCombinedCardFeedsFromAllFeeds, getWorkspaceCardFeedsStatus} from '@libs/CardFeedUtils'; +import { + filterAllInactiveCards, + forEachAssignedCard, + getCardFeedWithDomainID, + isBrokenConnectionPastDismissThreshold, + hasCardConnectionIssue, + isCardConnectionBroken, + isLastScrapePastDismissThreshold, + isPersonalCard, +} from '@libs/CardUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, CardFeeds, CardList, WorkspaceCardsList} from '@src/types/onyx'; +import type {CardErrors, CardFeedErrors, CardFeedErrorsObject, CardFeedErrorState} from '@src/types/onyx/DerivedValues'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {buildFeedKeysWithAssignedCards} from './Card'; + +const DEFAULT_CARD_FEED_ERROR_STATE: CardFeedErrorState = { + shouldShowRBR: false, + hasFeedErrors: false, + hasWorkspaceErrors: false, + isFeedConnectionBroken: false, + shouldPromptBrokenConnection: false, + hasFeedConnectionIssue: false, +}; + +function getShouldShowRBR(state: Partial): boolean { + if (state.hasFeedErrors) { + return true; + } + if (state.hasWorkspaceErrors) { + return true; + } + + // Deliberately keyed on the prompting flag, not `isFeedConnectionBroken`: past the grace period we stop showing + // the RBR while the connection stays broken so it can still be fixed. + return !!state.shouldPromptBrokenConnection; +} + +/** Builds the errors of every card feed, workspace and personal card from the card list, the workspace cards and the domain feeds. */ +function computeCardFeedErrors(globalCardList: OnyxEntry, allWorkspaceCards: OnyxCollection, cardFeeds: OnyxCollection): CardFeedErrors { + const feedKeysWithCards = buildFeedKeysWithAssignedCards(allWorkspaceCards); + const combinedCompanyCardFeeds = getCombinedCardFeedsFromAllFeeds(cardFeeds, undefined, feedKeysWithCards); + const workspaceCardFeedsStatus = getWorkspaceCardFeedsStatus(cardFeeds); + + const cardFeedErrors: CardFeedErrorsObject = {}; + const shouldShowRbrForWorkspaceAccountID: Record = {}; + const shouldShowRbrForFeedNameWithDomainID: Record = {}; + + const allFeedsState: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; + const companyCardFeedsState: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; + const expensifyCardFeedStates: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; + const personalCardStates: CardFeedErrorState = {...DEFAULT_CARD_FEED_ERROR_STATE}; + + const cardsWithBrokenFeedConnection: Record = {}; + const personalCardsWithBrokenConnection: Record = {}; + + function addErrorsForPersonalCard(card: Card) { + // Once the card has gone without a successful sync past the grace period we stop leading the user to it: the + // time-sensitive task and the RBR are removed. The connection error is a server-set `card.errors` entry, which + // is what lights the Account button via `hasPaymentMethodError`, so past the threshold it must not light the + // RBR. This is keyed on the last successful sync rather than `isCardConnectionBroken`, because the server sets + // the connection error even for scrape statuses that check ignores (e.g. 434). `errorFields` entries are left + // alone: they are written by a user-initiated action that failed (a manual sync, a reimbursable/start-date + // update), so they stay actionable no matter how old the connection is. The error itself stays on the card. + const isPastDismissThreshold = isLastScrapePastDismissThreshold(card); + const hasCardErrors = (!isPastDismissThreshold && !isEmptyObject(card.errors)) || !isEmptyObject(card.errorFields); + const cardErrors = { + ...(hasCardErrors + ? { + [card.cardID]: { + errors: card.errors, + errorFields: card.errorFields, + pendingAction: card.pendingAction, + }, + } + : {}), + } as Record; + + const isFeedConnectionBroken = hasCardConnectionIssue(card) && !isPastDismissThreshold; + // Track personal cards with broken feed connection + if (isFeedConnectionBroken) { + personalCardsWithBrokenConnection[card.cardID] = card; + } + const newFeedState: Omit = { + isFeedConnectionBroken, + // A personal card is fixed from its own details page, which reads the card directly, so there is no + // separate capability signal to preserve here. Prompting follows the same grace period. + shouldPromptBrokenConnection: isFeedConnectionBroken, + hasFeedErrors: !isEmptyObject(cardErrors), + hasWorkspaceErrors: false, + hasFeedConnectionIssue: hasCardConnectionIssue(card), + }; + const shouldShowRBR = getShouldShowRBR(newFeedState); + + personalCardStates.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken; + personalCardStates.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection; + personalCardStates.hasFeedErrors ||= newFeedState.hasFeedErrors; + personalCardStates.shouldShowRBR ||= shouldShowRBR; + } + + function addErrorsForCard(card: Card) { + const bankName = card.bank; + const workspaceAccountID = Number(card.fundID); + + const isExpensifyCard = bankName === CONST.EXPENSIFY_CARD.BANK; + + if (Number.isNaN(workspaceAccountID)) { + return; + } + + const feedNameWithDomainID = getCardFeedWithDomainID(bankName, workspaceAccountID); + const previousFeedErrors = cardFeedErrors[feedNameWithDomainID] ?? DEFAULT_CARD_FEED_ERROR_STATE; + + const feed = combinedCompanyCardFeeds?.[feedNameWithDomainID]; + const feedErrors = { + ...previousFeedErrors.feedErrors, + ...feed?.errors, + } as Errors; + const hasFeedErrors = feedNameWithDomainID ? !isEmptyObject(feedErrors) : false; + + const workspaceErrors = workspaceCardFeedsStatus?.[workspaceAccountID]?.errors; + const hasWorkspaceErrors = !!workspaceErrors; + + const hasCardErrors = !isEmptyObject(card.errors) || !isEmptyObject(card.errorFields) || card.pendingAction; + const cardErrors = { + ...previousFeedErrors.cardErrors, + ...(cardFeedErrors[feedNameWithDomainID]?.cardErrors ?? {}), + ...(hasCardErrors + ? { + [card.cardID]: { + errors: card.errors, + errorFields: card.errorFields, + pendingAction: card.pendingAction, + }, + } + : {}), + } as Record; + + // Keep the broken state itself truthful: the Company cards page renders its "log into your bank" fix from + // this flag, and the reconnect needs it to clear the error afterwards. Only stop *prompting* (the RBR and + // the time-sensitive task) once the connection has been unresolved past the grace period. + const isFeedConnectionBroken = isCardConnectionBroken(card); + const shouldPromptBrokenConnection = isFeedConnectionBroken && !isBrokenConnectionPastDismissThreshold(card); + + const newFeedState: Omit = { + isFeedConnectionBroken: isFeedConnectionBroken || previousFeedErrors.isFeedConnectionBroken, + shouldPromptBrokenConnection: shouldPromptBrokenConnection || previousFeedErrors.shouldPromptBrokenConnection, + hasFeedErrors: hasFeedErrors || previousFeedErrors.hasFeedErrors, + hasWorkspaceErrors: hasWorkspaceErrors || previousFeedErrors.hasWorkspaceErrors, + hasFeedConnectionIssue: hasCardConnectionIssue(card) || previousFeedErrors.hasFeedConnectionIssue, + }; + + const shouldShowRBR = getShouldShowRBR(newFeedState) || previousFeedErrors.shouldShowRBR; + + cardFeedErrors[feedNameWithDomainID] = { + ...newFeedState, + shouldShowRBR, + cardErrors, + feedErrors, + workspaceErrors, + }; + + // Track cards with broken feed connection. This stays truthful past the grace period so that reconnecting + // still clears the error (see useUpdateFeedBrokenConnection); consumers that prompt the user filter on the + // grace period themselves. + if (isFeedConnectionBroken) { + cardsWithBrokenFeedConnection[card.cardID] = card; + } + + // Update aggregate states - once true, always stays true + const cardTypeState = isExpensifyCard ? expensifyCardFeedStates : companyCardFeedsState; + + allFeedsState.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken; + allFeedsState.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection; + allFeedsState.hasFeedErrors ||= newFeedState.hasFeedErrors; + allFeedsState.hasWorkspaceErrors ||= newFeedState.hasWorkspaceErrors; + allFeedsState.hasFeedConnectionIssue ||= newFeedState.hasFeedConnectionIssue; + + cardTypeState.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken; + cardTypeState.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection; + cardTypeState.hasFeedErrors ||= newFeedState.hasFeedErrors; + cardTypeState.hasWorkspaceErrors ||= newFeedState.hasWorkspaceErrors; + cardTypeState.hasFeedConnectionIssue ||= newFeedState.hasFeedConnectionIssue; + + shouldShowRbrForWorkspaceAccountID[workspaceAccountID] ||= shouldShowRBR; + shouldShowRbrForFeedNameWithDomainID[feedNameWithDomainID] ||= shouldShowRBR; + } + + for (const card of Object.values(globalCardList ?? {})) { + const isPersonal = isPersonalCard(card); + if (isPersonal) { + addErrorsForPersonalCard(card); + } else { + addErrorsForCard(card); + } + } + + const workspaceCards = allWorkspaceCards ?? {}; + for (const key of Object.keys(workspaceCards)) { + const keyParts = key.split(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); + const workspaceCardParamParts = keyParts.at(1)?.split('_'); + + if (!workspaceCardParamParts) { + continue; + } + + forEachAssignedCard(filterAllInactiveCards(workspaceCards[key]), addErrorsForCard); + } + + allFeedsState.shouldShowRBR = getShouldShowRBR(allFeedsState); + companyCardFeedsState.shouldShowRBR = getShouldShowRBR(companyCardFeedsState); + expensifyCardFeedStates.shouldShowRBR = getShouldShowRBR(expensifyCardFeedStates); + + return { + // The errors of all card feeds. + cardFeedErrors, + cardsWithBrokenFeedConnection, + personalCardsWithBrokenConnection, + + // Mappings of whether to show the RBR for each workspace account ID and per feed name with domain ID + shouldShowRbrForWorkspaceAccountID, + shouldShowRbrForFeedNameWithDomainID, + + // Whether any of the feeds have one of the below errors + all: allFeedsState, + + // Whether any of the company cards have one of the below errors + companyCards: companyCardFeedsState, + + // Whether any of the expensify cards have one of the below errors + expensifyCard: expensifyCardFeedStates, + + // Whether any of the personal cards have one of the below errors + personalCard: personalCardStates, + }; +} + +type CardFeedErrorsCacheEntry = { + globalCardList: OnyxEntry; + allWorkspaceCards: OnyxCollection; + cardFeeds: OnyxCollection; + currentDate: OnyxEntry; + cardFeedErrors: CardFeedErrors; +}; + +let lastEntry: CardFeedErrorsCacheEntry | undefined; + +/** + * Memoized on the input references, which Onyx keeps stable until they change, so every mounted consumer shares one + * compute per write. The current date is part of the key because the grace-period checks read the clock. + */ +function getCardFeedErrors( + globalCardList: OnyxEntry, + allWorkspaceCards: OnyxCollection, + cardFeeds: OnyxCollection, + currentDate: OnyxEntry, +): CardFeedErrors { + if ( + lastEntry && + lastEntry.globalCardList === globalCardList && + lastEntry.allWorkspaceCards === allWorkspaceCards && + lastEntry.cardFeeds === cardFeeds && + lastEntry.currentDate === currentDate + ) { + return lastEntry.cardFeedErrors; + } + const cardFeedErrors = computeCardFeedErrors(globalCardList, allWorkspaceCards, cardFeeds); + lastEntry = {globalCardList, allWorkspaceCards, cardFeeds, currentDate, cardFeedErrors}; + return cardFeedErrors; +} + +export {computeCardFeedErrors, getCardFeedErrors}; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 3f5e2a136157..13971642abfd 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -184,11 +184,6 @@ type CardFeedErrors = { personalCard: CardFeedErrorState; }; -/** - * The derived value for card feed errors. - */ -type CardFeedErrorsDerivedValue = CardFeedErrors; - /** * The derived value for merged non-personal and workspace card feeds. */ @@ -254,7 +249,6 @@ export type { SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, - CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fad7cf8b5ab5..172e0b872cb4 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -59,7 +59,6 @@ import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type DatabaseSizeMeasurement from './DatabaseSizeMeasurement'; import type { - CardFeedErrorsDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, LoginToAccountIDMapDerivedValue, @@ -460,7 +459,6 @@ export type { SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, - CardFeedErrorsDerivedValue, LoginToAccountIDMapDerivedValue, GuideAccountIDsDerivedValue, SpendDataSignatureDerivedValue, diff --git a/tests/unit/CardFeedErrorsDerivedValueTest.ts b/tests/unit/CardFeedErrorsSelectorTest.ts similarity index 87% rename from tests/unit/CardFeedErrorsDerivedValueTest.ts rename to tests/unit/CardFeedErrorsSelectorTest.ts index ee3c32662a4a..e60fe12c6733 100644 --- a/tests/unit/CardFeedErrorsDerivedValueTest.ts +++ b/tests/unit/CardFeedErrorsSelectorTest.ts @@ -1,18 +1,13 @@ -import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; import {getCardFeedWithDomainID} from '@libs/CardUtils'; import CONST from '@src/CONST'; -import cardFeedErrorsConfig from '@src/libs/actions/OnyxDerived/configs/cardFeedErrors'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardFeeds, CardList, WorkspaceCardsList} from '@src/types/onyx'; import type {CardFeedWithDomainID, CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {OnyxCollection} from 'react-native-onyx'; -const DERIVED_VALUE_CONTEXT: DerivedValueContext = { - currentValue: undefined, - sourceValues: undefined, -}; +import {computeCardFeedErrors, getCardFeedErrors} from '@selectors/CardFeedErrors'; const CARD_FEEDS = { [CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]: { @@ -70,10 +65,10 @@ function createWorkspaceCardsList(cards: Record): WorkspaceCardsLi return cards; } -describe('CardFeedErrors Derived Value', () => { +describe('computeCardFeedErrors', () => { describe('compute function', () => { it('should return empty errors when no cards exist', () => { - const result = cardFeedErrorsConfig.compute([{}, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({}, {}, {}); expect(result.cardFeedErrors).toEqual({}); expect(result.cardsWithBrokenFeedConnection).toEqual({}); @@ -86,7 +81,7 @@ describe('CardFeedErrors Derived Value', () => { }); it('should return empty errors when all inputs are undefined', () => { - const result = cardFeedErrorsConfig.compute([undefined, undefined, undefined, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(undefined, undefined, undefined); expect(result.cardFeedErrors).toEqual({}); expect(result.all.shouldShowRBR).toBe(false); @@ -104,7 +99,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.isFeedConnectionBroken).toBe(true); expect(result.all.shouldShowRBR).toBe(true); @@ -122,7 +117,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.isFeedConnectionBroken).toBe(false); expect(result.cardsWithBrokenFeedConnection).toEqual({}); @@ -139,7 +134,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.isFeedConnectionBroken).toBe(false); }); @@ -155,7 +150,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.isFeedConnectionBroken).toBe(false); }); @@ -175,7 +170,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.shouldShowRBR).toBe(false); expect(result.all.shouldPromptBrokenConnection).toBe(false); @@ -201,7 +196,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.shouldShowRBR).toBe(true); expect(result.all.shouldPromptBrokenConnection).toBe(true); @@ -218,7 +213,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.isFeedConnectionBroken).toBe(true); expect(result.personalCardsWithBrokenConnection[CARD_IDS.card1]).toEqual(card); @@ -233,7 +228,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.isFeedConnectionBroken).toBe(false); expect(result.personalCardsWithBrokenConnection).toEqual({}); @@ -253,7 +248,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(true); // The connection itself is still dismissed, so the time-sensitive task stays away. @@ -270,7 +265,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(false); expect(result.personalCard.isFeedConnectionBroken).toBe(false); @@ -288,7 +283,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(true); expect(result.personalCard.isFeedConnectionBroken).toBe(true); @@ -309,7 +304,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(true); }); @@ -328,7 +323,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(false); }); @@ -345,7 +340,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(true); }); @@ -364,7 +359,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(false); expect(result.personalCard.isFeedConnectionBroken).toBe(false); @@ -381,7 +376,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.personalCard.shouldShowRBR).toBe(true); expect(result.personalCard.isFeedConnectionBroken).toBe(true); @@ -403,7 +398,7 @@ describe('CardFeedErrors Derived Value', () => { [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${cardFeed.policyAccountID}_${cardFeed.feedNameWithDomainID}`]: createWorkspaceCardsList({card2: card}), }; - const result = cardFeedErrorsConfig.compute([{}, allWorkspaceCards, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({}, allWorkspaceCards, {}); expect(result.all.isFeedConnectionBroken).toBe(true); expect(result.cardsWithBrokenFeedConnection[CARD_IDS.card2]).toEqual(card); @@ -453,7 +448,7 @@ describe('CardFeedErrors Derived Value', () => { }), }; - const result = cardFeedErrorsConfig.compute([{}, allWorkspaceCards, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({}, allWorkspaceCards, {}); // Only the active card should be processed and detected as broken expect(result.cardsWithBrokenFeedConnection).toHaveProperty(String(CARD_IDS.card4)); @@ -490,7 +485,7 @@ describe('CardFeedErrors Derived Value', () => { }, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, cardFeeds, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, cardFeeds); expect(result.all.hasFeedErrors).toBe(true); expect(result.all.shouldShowRBR).toBe(true); @@ -526,7 +521,7 @@ describe('CardFeedErrors Derived Value', () => { // }, // }; - // const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, cardFeeds, undefined], DERIVED_VALUE_CONTEXT); + // const result = computeCardFeedErrors(globalCardList, {}, {}, cardFeeds); // expect(result.all.hasWorkspaceErrors).toBe(true); // expect(result.cardFeedErrors[cardFeed.feedNameWithDomainID]?.hasWorkspaceErrors).toBe(true); @@ -548,7 +543,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.cardFeedErrors[cardFeed.feedNameWithDomainID]?.cardErrors[CARD_IDS.card1]).toEqual({ errors: {cardError: 'Card sync failed'}, @@ -571,7 +566,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.cardFeedErrors[cardFeed.feedNameWithDomainID]?.cardErrors[CARD_IDS.card1]?.errorFields).toEqual({ cardName: {error: 'Invalid card name'}, @@ -590,7 +585,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.cardFeedErrors[cardFeed.feedNameWithDomainID]?.cardErrors[CARD_IDS.card1]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); }); @@ -609,7 +604,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForWorkspaceAccountID[cardFeed.policyAccountID]).toBe(true); }); @@ -626,7 +621,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForFeedNameWithDomainID[cardFeed.feedNameWithDomainID]).toBe(true); }); @@ -643,7 +638,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForWorkspaceAccountID[cardFeed.policyAccountID]).toBe(false); expect(result.shouldShowRbrForFeedNameWithDomainID[cardFeed.feedNameWithDomainID]).toBe(false); @@ -671,7 +666,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card1, [CARD_IDS.card2]: card2}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForWorkspaceAccountID[cardFeed1.policyAccountID]).toBe(true); expect(result.shouldShowRbrForWorkspaceAccountID[cardFeed2.policyAccountID]).toBe(false); @@ -704,7 +699,7 @@ describe('CardFeedErrors Derived Value', () => { }), }; - const result = cardFeedErrorsConfig.compute([globalCardList, allWorkspaceCards, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, allWorkspaceCards, {}); expect(result.cardsWithBrokenFeedConnection).toHaveProperty(String(CARD_IDS.card1)); expect(result.cardsWithBrokenFeedConnection).toHaveProperty(String(CARD_IDS.card2)); @@ -739,7 +734,7 @@ describe('CardFeedErrors Derived Value', () => { }, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, cardFeeds, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, cardFeeds); expect(result.all.shouldShowRBR).toBe(true); }); @@ -756,7 +751,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.shouldShowRBR).toBe(true); }); @@ -773,7 +768,7 @@ describe('CardFeedErrors Derived Value', () => { const globalCardList: CardList = {[CARD_IDS.card1]: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.all.shouldShowRBR).toBe(false); }); @@ -811,7 +806,7 @@ describe('CardFeedErrors Derived Value', () => { [CARD_IDS.card2]: healthyCard, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForFeedNameWithDomainID[CHASE_FEED.feedNameWithDomainID]).toBe(true); expect(result.shouldShowRbrForFeedNameWithDomainID[AMEX_FEED.feedNameWithDomainID]).toBe(false); @@ -836,7 +831,7 @@ describe('CardFeedErrors Derived Value', () => { [CARD_IDS.card2]: fixedAmexCard, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForFeedNameWithDomainID[CHASE_FEED.feedNameWithDomainID]).toBe(true); expect(result.shouldShowRbrForFeedNameWithDomainID[AMEX_FEED.feedNameWithDomainID]).toBe(false); @@ -862,7 +857,7 @@ describe('CardFeedErrors Derived Value', () => { [CARD_IDS.card2]: healthyAmexCard, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForFeedNameWithDomainID[CHASE_FEED.feedNameWithDomainID]).toBe(false); expect(result.shouldShowRbrForFeedNameWithDomainID[AMEX_FEED.feedNameWithDomainID]).toBe(false); @@ -888,7 +883,7 @@ describe('CardFeedErrors Derived Value', () => { [CARD_IDS.card2]: brokenAmexCard, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForFeedNameWithDomainID[CHASE_FEED.feedNameWithDomainID]).toBe(true); expect(result.shouldShowRbrForFeedNameWithDomainID[AMEX_FEED.feedNameWithDomainID]).toBe(true); @@ -933,7 +928,7 @@ describe('CardFeedErrors Derived Value', () => { }, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, cardFeeds, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, cardFeeds); expect(result.shouldShowRbrForFeedNameWithDomainID[AMEX_FEED.feedNameWithDomainID]).toBe(true); expect(result.shouldShowRbrForFeedNameWithDomainID[CHASE_FEED.feedNameWithDomainID]).toBe(false); @@ -941,3 +936,55 @@ describe('CardFeedErrors Derived Value', () => { }); }); }); + +describe('getCardFeedErrors', () => { + const cardFeed = CARD_FEEDS[CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]; + + function createBrokenCardList(): CardList { + return {card1: createCard({cardID: CARD_IDS.card1, bank: cardFeed.feedName, fundID: String(cardFeed.policyAccountID), lastScrapeResult: 403})}; + } + + it('should return the same object while every input keeps its reference', () => { + // Given one set of inputs, as every consumer reads them from the same Onyx store between two writes + const globalCardList = createBrokenCardList(); + const allWorkspaceCards: OnyxCollection = {}; + const cardFeeds: OnyxCollection = {}; + + // When two consumers ask for the errors + const first = getCardFeedErrors(globalCardList, allWorkspaceCards, cardFeeds, '2026-10-08'); + const second = getCardFeedErrors(globalCardList, allWorkspaceCards, cardFeeds, '2026-10-08'); + + // Then they share one compute, so many mounted report actions don't each rebuild the errors + expect(second).toBe(first); + expect(first.all.isFeedConnectionBroken).toBe(true); + }); + + it('should recompute when an input changes reference', () => { + // Given errors computed from a card list with a broken card + const allWorkspaceCards: OnyxCollection = {}; + const cardFeeds: OnyxCollection = {}; + const first = getCardFeedErrors(createBrokenCardList(), allWorkspaceCards, cardFeeds, '2026-10-08'); + + // When the card list is replaced by one where the card syncs again + const fixedCardList: CardList = {card1: createCard({cardID: CARD_IDS.card1, bank: cardFeed.feedName, fundID: String(cardFeed.policyAccountID), lastScrapeResult: 200})}; + const second = getCardFeedErrors(fixedCardList, allWorkspaceCards, cardFeeds, '2026-10-08'); + + // Then the errors follow the new card list instead of the cached result + expect(second).not.toBe(first); + expect(second.all.isFeedConnectionBroken).toBe(false); + }); + + it('should recompute when the day changes even if the card data does not', () => { + // Given errors computed for one day + const globalCardList = createBrokenCardList(); + const allWorkspaceCards: OnyxCollection = {}; + const cardFeeds: OnyxCollection = {}; + const first = getCardFeedErrors(globalCardList, allWorkspaceCards, cardFeeds, '2026-10-08'); + + // When the current date rolls over with the same card data + const second = getCardFeedErrors(globalCardList, allWorkspaceCards, cardFeeds, '2026-10-09'); + + // Then the errors are rebuilt, because the grace-period checks read the clock + expect(second).not.toBe(first); + }); +}); diff --git a/tests/unit/CompanyCardsRBRTest.ts b/tests/unit/CompanyCardsRBRTest.ts index 1f29a4508f3e..d5a188ebd4a1 100644 --- a/tests/unit/CompanyCardsRBRTest.ts +++ b/tests/unit/CompanyCardsRBRTest.ts @@ -1,17 +1,12 @@ -import type {DerivedValueContext} from '@libs/actions/OnyxDerived/types'; import {getCardFeedWithDomainID} from '@libs/CardUtils'; import CONST from '@src/CONST'; -import cardFeedErrorsConfig from '@src/libs/actions/OnyxDerived/configs/cardFeedErrors'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardFeeds, CardList, WorkspaceCardsList} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -const DERIVED_VALUE_CONTEXT: DerivedValueContext = { - currentValue: undefined, - sourceValues: undefined, -}; +import {computeCardFeedErrors} from '@selectors/CardFeedErrors'; const WORKSPACE_ACCOUNT_ID = 99999999; @@ -62,7 +57,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }); const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); expect(result.all.isFeedConnectionBroken).toBe(true); @@ -84,7 +79,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }), }; - const result = cardFeedErrorsConfig.compute([{}, allWorkspaceCards, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({}, allWorkspaceCards, {}); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); expect(result.all.isFeedConnectionBroken).toBe(true); @@ -100,7 +95,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }); const globalCardList: CardList = {card1: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(false); expect(result.all.isFeedConnectionBroken).toBe(false); @@ -118,7 +113,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }); const globalCardList: CardList = {card3: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); expect(result.all.isFeedConnectionBroken).toBe(true); @@ -138,7 +133,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { [`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${PLAID_FEED.policyAccountID}_${PLAID_FEED.feedNameWithDomainID}`]: createWorkspaceCardsList({card4: card}), }; - const result = cardFeedErrorsConfig.compute([{}, allWorkspaceCards, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({}, allWorkspaceCards, {}); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); expect(result.all.isFeedConnectionBroken).toBe(true); @@ -154,7 +149,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }); const globalCardList: CardList = {card3: card}; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, {}); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(false); expect(result.all.isFeedConnectionBroken).toBe(false); @@ -188,7 +183,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, cardFeeds, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, cardFeeds); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); expect(result.all.hasFeedErrors).toBe(true); @@ -220,7 +215,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }, }; - const result = cardFeedErrorsConfig.compute([globalCardList, {}, cardFeeds, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors(globalCardList, {}, cardFeeds); expect(result.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); expect(result.all.hasFeedErrors).toBe(true); @@ -238,7 +233,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { lastScrapeResult: 403, }); - const brokenResult = cardFeedErrorsConfig.compute([{card1: brokenCard}, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const brokenResult = computeCardFeedErrors({card1: brokenCard}, {}, {}); expect(brokenResult.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); // Then: connection is fixed (lastScrapeResult returns to 200) @@ -249,7 +244,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { lastScrapeResult: 200, }); - const fixedResult = cardFeedErrorsConfig.compute([{card1: fixedCard}, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const fixedResult = computeCardFeedErrors({card1: fixedCard}, {}, {}); expect(fixedResult.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(false); expect(fixedResult.all.shouldShowRBR).toBe(false); expect(fixedResult.cardsWithBrokenFeedConnection).toEqual({}); @@ -264,7 +259,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { lastScrapeResult: 401, }); - const brokenResult = cardFeedErrorsConfig.compute([{card3: brokenCard}, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const brokenResult = computeCardFeedErrors({card3: brokenCard}, {}, {}); expect(brokenResult.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); // Then: Plaid re-authenticated successfully @@ -275,7 +270,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { lastScrapeResult: 200, }); - const fixedResult = cardFeedErrorsConfig.compute([{card3: fixedCard}, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const fixedResult = computeCardFeedErrors({card3: fixedCard}, {}, {}); expect(fixedResult.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(false); expect(fixedResult.all.shouldShowRBR).toBe(false); expect(fixedResult.cardsWithBrokenFeedConnection).toEqual({}); @@ -298,7 +293,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }), }; - const result = cardFeedErrorsConfig.compute([{}, allWorkspaceCards, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({}, allWorkspaceCards, {}); expect(result.cardsWithBrokenFeedConnection).not.toHaveProperty('1'); }); @@ -318,7 +313,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { }), }; - const result = cardFeedErrorsConfig.compute([{}, allWorkspaceCards, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({}, allWorkspaceCards, {}); expect(result.cardsWithBrokenFeedConnection).not.toHaveProperty('3'); }); @@ -333,7 +328,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { lastScrapeResult: status, }); - const result = cardFeedErrorsConfig.compute([{card1: card}, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({card1: card}, {}, {}); expect(result.all.isFeedConnectionBroken).toBe(false); expect(result.cardsWithBrokenFeedConnection).toEqual({}); @@ -347,7 +342,7 @@ describe('Company Cards RBR - broken bank/Plaid connection', () => { lastScrapeResult: status, }); - const result = cardFeedErrorsConfig.compute([{card3: card}, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + const result = computeCardFeedErrors({card3: card}, {}, {}); expect(result.all.isFeedConnectionBroken).toBe(false); expect(result.cardsWithBrokenFeedConnection).toEqual({}); diff --git a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts index fa60cd35a21f..9b6e12e319b0 100644 --- a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts +++ b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts @@ -145,6 +145,13 @@ jest.mock('@hooks/useOnyx', () => ({ default: (key: string, options?: {selector?: (v: unknown) => unknown}) => mockUseOnyx(key, options), })); +let mockCardFeedErrors: CardFeedErrors; + +jest.mock('@hooks/useCardFeedErrors', () => ({ + __esModule: true, + default: () => mockCardFeedErrors, +})); + // Helpers function makeCorporatePolicy(overrides: Partial = {}): Policy { @@ -220,7 +227,7 @@ function setupCardGroups(groups: CardGroupFixture[], searchOverrides: Partial = {}): CardFeedErrors { const defaultState: CardFeedErrorState = { shouldShowRBR: false, @@ -280,6 +287,7 @@ beforeEach(() => { for (const k of Object.keys(onyxData)) { delete onyxData[k]; } + mockCardFeedErrors = makeCardFeedErrors(); mockUseOnyx.mockClear(); mockedSearch.mockClear(); mockedUseIsTabFocused.mockReturnValue(true); @@ -772,20 +780,20 @@ describe('useYourSpendData — third-party cardRows', () => { // Start: card is in broken-feed-connection map → row absent. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const brokenCard = makeThirdPartyCards([{cardID: THIRD_PARTY_CARD_ID_1, lastFourPAN: THIRD_PARTY_LAST_FOUR_1}]).at(0)!; - onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS] = makeCardFeedErrors({cardsWithBrokenFeedConnection: {[THIRD_PARTY_CARD_ID_1]: brokenCard}}); + mockCardFeedErrors = makeCardFeedErrors({cardsWithBrokenFeedConnection: {[THIRD_PARTY_CARD_ID_1]: brokenCard}}); const {result, rerender} = renderHook(() => useYourSpendData()); expect(result.current.cardRows).toHaveLength(0); - // Mutate Onyx: remove the broken-feed entry → row should appear on re-render. + // Remove the broken-feed entry → row should appear on re-render. act(() => { - onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS] = makeCardFeedErrors({cardsWithBrokenFeedConnection: {}}); + mockCardFeedErrors = makeCardFeedErrors({cardsWithBrokenFeedConnection: {}}); }); rerender(undefined); expect(result.current.cardRows).toHaveLength(1); // And back: re-add the broken-feed entry → row should disappear. act(() => { - onyxData[ONYXKEYS.DERIVED.CARD_FEED_ERRORS] = makeCardFeedErrors({cardsWithBrokenFeedConnection: {[THIRD_PARTY_CARD_ID_1]: brokenCard}}); + mockCardFeedErrors = makeCardFeedErrors({cardsWithBrokenFeedConnection: {[THIRD_PARTY_CARD_ID_1]: brokenCard}}); }); rerender(undefined); expect(result.current.cardRows).toHaveLength(0); diff --git a/tests/unit/hooks/useAssignCard.test.ts b/tests/unit/hooks/useAssignCard.test.ts index 7e32d1926411..eb8c5eb8c94a 100644 --- a/tests/unit/hooks/useAssignCard.test.ts +++ b/tests/unit/hooks/useAssignCard.test.ts @@ -2,6 +2,7 @@ import {renderHook} from '@testing-library/react-native'; import useAssignCard from '@hooks/useAssignCard'; +import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useCardFeeds from '@hooks/useCardFeeds'; import useIsAllowedToIssueCompanyCard from '@hooks/useIsAllowedToIssueCompanyCard'; import useNetwork from '@hooks/useNetwork'; @@ -68,6 +69,11 @@ jest.mock('@hooks/useOnyx', () => ({ })); // Mock all the hooks +jest.mock('@hooks/useCardFeedErrors', () => ({ + __esModule: true, + default: jest.fn(), +})); + jest.mock('@hooks/useCardFeeds', () => ({ __esModule: true, default: jest.fn(), @@ -137,6 +143,7 @@ describe('useAssignCard', () => { jest.mocked(useNetwork).mockReturnValue({isOffline: false}); jest.mocked(useIsAllowedToIssueCompanyCard).mockReturnValue(true); jest.mocked(useOnyx).mockReturnValue([undefined, {status: 'loaded'}]); + jest.mocked(useCardFeedErrors).mockReturnValue(createMock({cardFeedErrors: {}})); }); afterEach(async () => { @@ -194,17 +201,12 @@ describe('useAssignCard', () => { expect(result.current.isAssigningCardDisabled).toBe(false); }); - /** Points the mocked useOnyx at a CARD_FEED_ERRORS value for the custom (commercial) feed. */ + /** Points the mocked useCardFeedErrors at an error state for the custom (commercial) feed. */ function mockFeedErrors(feedErrorState: Partial) { - jest.mocked(useOnyx).mockImplementation((key) => - key === ONYXKEYS.DERIVED.CARD_FEED_ERRORS - ? [ - createMock({ - cardFeedErrors: {[mockCustomFeed]: {shouldShowRBR: false, hasFeedErrors: false, hasWorkspaceErrors: false, isFeedConnectionBroken: false, ...feedErrorState}}, - }), - {status: 'loaded'}, - ] - : [undefined, {status: 'loaded'}], + jest.mocked(useCardFeedErrors).mockReturnValue( + createMock({ + cardFeedErrors: {[mockCustomFeed]: {shouldShowRBR: false, hasFeedErrors: false, hasWorkspaceErrors: false, isFeedConnectionBroken: false, ...feedErrorState}}, + }), ); } diff --git a/tests/unit/useCardFeedErrorsTest.ts b/tests/unit/useCardFeedErrorsTest.ts new file mode 100644 index 000000000000..003bc1883450 --- /dev/null +++ b/tests/unit/useCardFeedErrorsTest.ts @@ -0,0 +1,105 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useCardFeedErrors from '@hooks/useCardFeedErrors'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const WORKSPACE_ACCOUNT_ID = 44444444; +const BROKEN_CARD_ID = 1; +const HEALTHY_PERSONAL_CARD_ID = 2; + +function createBrokenCard(overrides: Partial = {}): Card { + return { + cardID: BROKEN_CARD_ID, + accountID: 1, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE, + fundID: String(WORKSPACE_ACCOUNT_ID), + cardName: 'Broken card', + domainName: 'test.exfy', + fraud: 'none', + lastFourPAN: '1234', + lastScrape: '', + lastUpdated: '', + lastScrapeResult: 403, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + ...overrides, + }; +} + +describe('useCardFeedErrors', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('should return the errors computed from the card inputs', async () => { + // Given a company card whose bank connection is broken + await Onyx.set(ONYXKEYS.CARD_LIST, {[BROKEN_CARD_ID]: createBrokenCard()}); + await waitForBatchedUpdates(); + + // When the errors are read + const {result} = renderHook(() => useCardFeedErrors()); + + // Then the broken card lights the RBR of its workspace, as the derived value did + expect(result.current.all.isFeedConnectionBroken).toBe(true); + expect(result.current.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(true); + }); + + it('should keep the same reference when a card write leaves the errors unchanged', async () => { + // Given the errors of a broken card + await Onyx.set(ONYXKEYS.CARD_LIST, {[BROKEN_CARD_ID]: createBrokenCard()}); + await waitForBatchedUpdates(); + const {result} = renderHook(() => useCardFeedErrors()); + const before = result.current; + + // When a healthy personal card is added, which doesn't change any error + await act(async () => { + await Onyx.merge(ONYXKEYS.CARD_LIST, {[HEALTHY_PERSONAL_CARD_ID]: createBrokenCard({cardID: HEALTHY_PERSONAL_CARD_ID, fundID: '0', lastScrapeResult: 200})}); + await waitForBatchedUpdates(); + }); + + // Then consumers get the previous object, so they don't re-render + expect(result.current).toBe(before); + }); + + it('should apply a selector so a row only follows its own workspace', async () => { + // Given a broken card on one workspace + await Onyx.set(ONYXKEYS.CARD_LIST, {[BROKEN_CARD_ID]: createBrokenCard()}); + await waitForBatchedUpdates(); + + // When a row of that workspace and a row of another workspace select their flag + const {result: brokenRow} = renderHook(() => useCardFeedErrors((errors) => !!errors.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID])); + const {result: otherRow} = renderHook(() => useCardFeedErrors((errors) => !!errors.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID + 1])); + + // Then only the row of the broken workspace shows the RBR + expect(brokenRow.current).toBe(true); + expect(otherRow.current).toBe(false); + }); + + it('should update when a card write fixes the connection', async () => { + // Given the errors of a broken card + await Onyx.set(ONYXKEYS.CARD_LIST, {[BROKEN_CARD_ID]: createBrokenCard()}); + await waitForBatchedUpdates(); + const {result} = renderHook(() => useCardFeedErrors()); + + // When the card syncs again + await act(async () => { + await Onyx.merge(ONYXKEYS.CARD_LIST, {[BROKEN_CARD_ID]: {lastScrapeResult: 200}}); + await waitForBatchedUpdates(); + }); + + // Then the RBR goes away + expect(result.current.all.isFeedConnectionBroken).toBe(false); + expect(result.current.shouldShowRbrForWorkspaceAccountID[WORKSPACE_ACCOUNT_ID]).toBe(false); + }); +}); From c49a7e8b9d58e52004f27b3eb75bebf06ff51825 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 8 Oct 2026 17:08:57 +0200 Subject: [PATCH 2/3] Add reassure test for card feed errors on card writes --- .../CardFeedErrorsCardWrites.perf-test.tsx | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 tests/perf-test/CardFeedErrorsCardWrites.perf-test.tsx diff --git a/tests/perf-test/CardFeedErrorsCardWrites.perf-test.tsx b/tests/perf-test/CardFeedErrorsCardWrites.perf-test.tsx new file mode 100644 index 000000000000..9fcfc0fd2016 --- /dev/null +++ b/tests/perf-test/CardFeedErrorsCardWrites.perf-test.tsx @@ -0,0 +1,347 @@ +import {render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import TransactionPreviewContent from '@components/ReportActionItem/TransactionPreview/TransactionPreviewContent'; +import WorkspaceRowBrickRoadIndicator from '@components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, PersonalDetailsList, Policy, Report, ReportAction, Transaction, WorkspaceCardsList} from '@src/types/onyx'; + +import React from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import createMock from '../utils/createMock'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + __esModule: true, + default: () => ({ + didScreenTransitionEnd: true, + }), +})); + +const WORKSPACE_COUNT = 20; +const PREVIEW_COUNT = 10; +const SMALL_CARDS_PER_WORKSPACE = 5; +const LARGE_CARDS_PER_WORKSPACE = 100; +const WRITES_PER_SCENARIO = 10; +const FIRST_WORKSPACE_ACCOUNT_ID = 70000; +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'email1@test.com'; +const FEED = CONST.COMPANY_CARD.FEED_BANK_NAME.VISA; +const HEALTHY_SCRAPE_RESULT = 200; +const BROKEN_SCRAPE_RESULT = 403; + +const workspaceIndexes = Array.from({length: WORKSPACE_COUNT}, (value, index) => index); +const previewIndexes = Array.from({length: PREVIEW_COUNT}, (value, index) => index); + +function getPolicyID(workspaceIndex: number) { + return `cardFeedErrorsPolicy${workspaceIndex}`; +} + +function getWorkspaceAccountID(workspaceIndex: number) { + return FIRST_WORKSPACE_ACCOUNT_ID + workspaceIndex; +} + +function getWorkspaceCardsKey(workspaceIndex: number) { + return `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${getWorkspaceAccountID(workspaceIndex)}_${FEED}` as const; +} + +function getCardID(workspaceIndex: number, cardIndex: number) { + return getWorkspaceAccountID(workspaceIndex) * 1000 + cardIndex; +} + +function createCard(workspaceIndex: number, cardIndex: number): Card { + return { + cardID: getCardID(workspaceIndex, cardIndex), + accountID: CURRENT_USER_ACCOUNT_ID, + bank: FEED, + fundID: String(getWorkspaceAccountID(workspaceIndex)), + cardName: `Card ${cardIndex}`, + domainName: 'test.exfy', + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastFourPAN: '1234', + lastScrape: '', + lastUpdated: '', + lastScrapeResult: HEALTHY_SCRAPE_RESULT, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + }; +} + +function createWorkspaceCards(cardsPerWorkspace: number) { + return Object.fromEntries( + workspaceIndexes.map((workspaceIndex) => { + const cards: WorkspaceCardsList = Object.fromEntries( + Array.from({length: cardsPerWorkspace}, (value, cardIndex) => [String(getCardID(workspaceIndex, cardIndex)), createCard(workspaceIndex, cardIndex)]), + ); + return [getWorkspaceCardsKey(workspaceIndex), cards]; + }), + ); +} + +const policies = Object.fromEntries( + workspaceIndexes.map((workspaceIndex) => [ + `${ONYXKEYS.COLLECTION.POLICY}${getPolicyID(workspaceIndex)}`, + createMock({ + id: getPolicyID(workspaceIndex), + name: `Workspace ${workspaceIndex}`, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + owner: CURRENT_USER_EMAIL, + outputCurrency: CONST.CURRENCY.USD, + policyAccountID: getWorkspaceAccountID(workspaceIndex), + }), + ]), +); + +const personalDetails: PersonalDetailsList = { + [CURRENT_USER_ACCOUNT_ID]: {accountID: CURRENT_USER_ACCOUNT_ID, login: CURRENT_USER_EMAIL, displayName: 'One'}, +}; + +function getReportID(previewIndex: number) { + return `cardFeedErrorsReport${previewIndex}`; +} + +function getTransactionID(previewIndex: number) { + return `cardFeedErrorsTransaction${previewIndex}`; +} + +const previewReports = previewIndexes.map((previewIndex) => + createMock({ + reportID: getReportID(previewIndex), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: getPolicyID(0), + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }), +); + +const previewTransactions = previewIndexes.map((previewIndex) => + createMock({ + transactionID: getTransactionID(previewIndex), + reportID: getReportID(previewIndex), + amount: 5000, + currency: CONST.CURRENCY.USD, + created: '2026-04-20', + merchant: `Merchant ${previewIndex}`, + comment: {}, + }), +); + +const previewActions = previewIndexes.map((previewIndex) => + createMock({ + ...LHNTestUtils.getFakeReportAction(), + reportActionID: `cardFeedErrorsAction${previewIndex}`, + reportID: getReportID(previewIndex), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: CURRENT_USER_ACCOUNT_ID, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: getTransactionID(previewIndex), + amount: 5000, + currency: CONST.CURRENCY.USD, + }, + }), +); + +const noop = () => {}; + +/** Workspace list rows, each subscribed to whether its own workspace shows the card feed RBR */ +function WorkspaceRows() { + return ( + + {workspaceIndexes.map((workspaceIndex) => ( + + ))} + + ); +} + +/** Expense previews, the report-action items that read the whole card feed errors value */ +function TransactionPreviews() { + return ( + <> + {previewIndexes.map((previewIndex) => ( + ({})} + isWhisper={false} + isHovered={false} + chatReport={undefined} + personalDetails={personalDetails} + report={previewReports.at(previewIndex)} + policy={undefined} + transaction={previewTransactions.at(previewIndex)} + violations={[]} + transactionRawAmount={5000} + offlineWithFeedbackOnClose={noop} + containerStyles={[]} + transactionPreviewWidth={303} + isBillSplit={false} + areThereDuplicates={false} + sessionAccountID={CURRENT_USER_ACCOUNT_ID} + walletTermsErrors={undefined} + reportPreviewAction={undefined} + navigateToReviewFields={noop} + routeName="Report" + /> + ))} + + ); +} + +/** Report-action previews and workspace rows mounted together, so every write reaches many card feed error consumers */ +function CardFeedErrorsConsumers() { + return ( + + + + + ); +} + +async function seed(cardsPerWorkspace: number) { + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails, + ...policies, + ...createWorkspaceCards(cardsPerWorkspace), + ...Object.fromEntries(previewReports.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report])), + ...Object.fromEntries(previewTransactions.map((transaction) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction])), + }); + await waitForBatchedUpdates(); +} + +/** Renames a healthy card, which changes no error */ +async function renameHealthyCard(label: string) { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(getWorkspaceCardsKey(0), {[getCardID(0, 0)]: {cardName: `Renamed ${label}-${index}`}}); + await waitForBatchedUpdates(); + } +} + +/** Breaks and repairs one card's bank connection, so every write changes the errors; ends healthy so runs can repeat */ +async function toggleBrokenCard() { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + const lastScrapeResult = index % 2 === 0 ? BROKEN_SCRAPE_RESULT : HEALTHY_SCRAPE_RESULT; + await Onyx.merge(getWorkspaceCardsKey(0), {[getCardID(0, 0)]: {lastScrapeResult}}); + await waitForBatchedUpdates(); + } +} + +describe('Card feed errors consumers on card writes', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + await TestHelper.signInWithTestUser(CURRENT_USER_ACCOUNT_ID, CURRENT_USER_EMAIL, undefined, undefined, 'One'); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('[CardFeedErrors] should re-render when a healthy card is renamed', async () => { + // Given ten expense previews and twenty workspace rows over a small set of healthy workspace cards + await seed(SMALL_CARDS_PER_WORKSPACE); + + // Reassure runs the scenario several times against one seeded Onyx, so every run writes a name that doesn't exist yet + let run = 0; + const scenario = async () => { + await screen.findByTestId('workspace-rows'); + + // When a healthy card is renamed repeatedly, which leaves the card feed errors unchanged + await renameHealthyCard(`small-${run}`); + run++; + }; + + // Then reassure measures what the unrelated writes cost the consumers + await measureRenders(, {scenario}); + }); + + test('[CardFeedErrors] should not re-render workspace rows when a healthy card is renamed', async () => { + // Given only the workspace rows, which don't read the report attributes, so their renders isolate card feed error churn + await seed(SMALL_CARDS_PER_WORKSPACE); + + let run = 0; + const scenario = async () => { + await screen.findByTestId('workspace-rows'); + + // When a healthy card is renamed repeatedly + await renameHealthyCard(`rows-${run}`); + run++; + }; + + // Then every extra render comes from the card feed errors changing without a new RBR state + await measureRenders(, {scenario}); + }); + + test('[CardFeedErrors] should re-render when a healthy card is renamed in a large card list', async () => { + // Given the same consumers over 2000 workspace cards + await seed(LARGE_CARDS_PER_WORKSPACE); + + let run = 0; + const scenario = async () => { + await screen.findByTestId('workspace-rows'); + + // When a healthy card is renamed repeatedly, so every write rebuilds the errors from every card + await renameHealthyCard(`large-${run}`); + run++; + }; + + // Then reassure measures the render cost, which grows if each consumer rebuilds the errors on its own + await measureRenders(, {scenario}); + }); + + test('[CardFeedErrors] should re-render when a card connection breaks and is repaired', async () => { + // Given the consumers over a small set of healthy workspace cards + await seed(SMALL_CARDS_PER_WORKSPACE); + + const scenario = async () => { + await screen.findByTestId('workspace-rows'); + + // When one card's bank connection breaks and is repaired, so the errors and the first workspace's RBR really change + await toggleBrokenCard(); + }; + + // Then reassure measures the cost of a real error change, which both versions have to pay + await measureRenders(, {scenario}); + }); + + test('[CardFeedErrors] should process healthy card renames in a large card list', async () => { + // Given the consumers mounted over 2000 workspace cards + await seed(LARGE_CARDS_PER_WORKSPACE); + render(); + await screen.findByTestId('workspace-rows'); + + // When a healthy card is renamed repeatedly + let run = 0; + const writeRenames = async () => { + await renameHealthyCard(`timed-${run}`); + run++; + }; + + // Then reassure times the whole write loop, including work done outside React renders such as derived computes and their writes + await measureAsyncFunction(writeRenames); + }); +}); From 9ef3c3a699283cfcdfa3c3b4af17467a851c6298 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 15 Oct 2026 02:24:34 +0700 Subject: [PATCH 3/3] Show empty state instead of No results when default view hides all rows --- src/components/Table/TableBody.tsx | 7 +++- .../TableEmptyStates/TableNoResultsState.tsx | 5 ++- tests/ui/TableTest.tsx | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 9a4db2f7aead..faa96b4da20f 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -164,6 +164,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. noResultsStateElement, tableListMetadata, isEmptyResult, + isDefaultViewEmpty, scrollWidth, tableWidth, } = useTableContext(); @@ -337,8 +338,10 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ); - const emptyStateContent = - tableListMetadata.hasPageHeader && isEmptyResult ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); + // When the default view hides every row (e.g. all workspaces are archived), no search or filter is active, + // so show the empty state rather than "No results". + const shouldShowNoResultsState = tableListMetadata.hasPageHeader && isEmptyResult && !isDefaultViewEmpty; + const emptyStateContent = shouldShowNoResultsState ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); const footerElement = renderListComponent(ListFooterComponent); // Consumer footer flex growth is useful below normal rows, but inside the combined empty-state // footer it can expand over the page header. Preserve the remaining style while disabling growth. diff --git a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx index 60a93ca4a2c6..30c49a66fe35 100644 --- a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx +++ b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx @@ -21,10 +21,11 @@ type TableNoResultsStateProps = Omit { expect(screen.queryByTestId('row-2')).toBeNull(); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); }); + + it('renders the empty state instead of "No results" when a page-header table hides every row by default', () => { + // Given a page-header table (like the Workspaces list) whose default filter hides every row, with both + // a no-results state and an empty state declared + const props = createDefaultProps(); + const data: TestItem[] = [ + {keyForList: '1', id: '1', name: 'Archived A', category: 'archived', value: 1}, + {keyForList: '2', id: '2', name: 'Archived B', category: 'archived', value: 2}, + ]; + + // When the table renders with no search or filter selected + render( + + data={data} + columns={props.columns} + renderItem={props.renderItem} + keyExtractor={props.keyExtractor} + filters={statusFilterConfig} + isItemInFilter={isItemInStatusFilter} + > + + Page header + + + + + + , + ); + + // Then the empty state shows inside the list with the page header still visible, because the user did + // not search or filter, so "No results found" would be misleading + expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('table-header-component')).toBeTruthy(); + expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); + expect(screen.getByText('No workspaces')).toBeTruthy(); + expect(screen.queryByText('common.noResultsFound')).toBeNull(); + }); }); describe('immediate filter', () => {