diff --git a/src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts b/src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts index dcc020035351..dbef7cf37bd3 100644 --- a/src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts +++ b/src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts @@ -53,7 +53,15 @@ export default createOnyxDerivedValueConfig({ const personalCardsWithBrokenConnection: Record = {}; function addErrorsForPersonalCard(card: Card) { - const hasCardErrors = !isEmptyObject(card.errors) || !isEmptyObject(card.errorFields); + // Once the broken connection is unresolved past the grace period we stop leading the user to it: the + // time-sensitive task and the RBR are removed. The broken-connection error surfaces via + // `errorFields.lastScrape`, so drop only that field when deciding the RBR — any other actionable error on + // the card (a failed reimbursable/start-date update in another `errorFields` entry, a failed remove in + // `card.errors`, etc.) is kept so it still surfaces. The error itself stays on the card so it's fixable. + const isPastDismissThreshold = isBrokenConnectionPastDismissThreshold(card); + const errorFieldsForRBR = + isPastDismissThreshold && card.errorFields ? Object.fromEntries(Object.entries(card.errorFields).filter(([field]) => field !== 'lastScrape')) : card.errorFields; + const hasCardErrors = !isEmptyObject(card.errors) || !isEmptyObject(errorFieldsForRBR); const cardErrors = { ...(hasCardErrors ? { @@ -66,9 +74,7 @@ export default createOnyxDerivedValueConfig({ : {}), } as Record; - // Stop surfacing the broken connection (task + RBR) once it has been unresolved past the - // grace period; the underlying error on the card is kept so the user can still fix it. - const isFeedConnectionBroken = isCardConnectionBroken(card) && !isBrokenConnectionPastDismissThreshold(card); + const isFeedConnectionBroken = isCardConnectionBroken(card) && !isPastDismissThreshold; // Track personal cards with broken feed connection if (isFeedConnectionBroken) { personalCardsWithBrokenConnection[card.cardID] = card; diff --git a/tests/unit/CardFeedErrorsDerivedValueTest.ts b/tests/unit/CardFeedErrorsDerivedValueTest.ts index 322209ae7f60..6dd517591a4a 100644 --- a/tests/unit/CardFeedErrorsDerivedValueTest.ts +++ b/tests/unit/CardFeedErrorsDerivedValueTest.ts @@ -211,6 +211,95 @@ describe('CardFeedErrors Derived Value', () => { expect(result.personalCard.isFeedConnectionBroken).toBe(false); expect(result.personalCardsWithBrokenConnection).toEqual({}); }); + + // A broken personal card surfaces its connection error via errorFields.lastScrape (this is what the card + // detail page reads). Past the grace period we stop leading the user to it, so that specific error must not + // light the Account/Wallet RBR — while the error itself is kept so the card can still be fixed. + it('should NOT show the RBR for a personal card past the grace period whose only error is the broken connection', () => { + const card = createCard({ + cardID: CARD_IDS.card1, + lastScrapeResult: 403, // Broken connection + lastScrape: '2020-01-01 00:00:00', // Last successful scrape is well beyond the grace period + errorFields: {lastScrape: {error: 'Your card connection is broken.'}}, // Kept so the card can still be fixed + }); + + const globalCardList: CardList = {card1: card}; + + const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + + expect(result.personalCard.shouldShowRBR).toBe(false); + expect(result.personalCard.isFeedConnectionBroken).toBe(false); + }); + + it('should still show the RBR for a broken personal card within the grace period', () => { + // Broken only since yesterday, so it is still well inside the grace period and must keep prompting. + const recentScrape = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' '); + const card = createCard({ + cardID: CARD_IDS.card1, + lastScrapeResult: 403, + lastScrape: recentScrape, + errorFields: {lastScrape: {error: 'Your card connection is broken.'}}, + }); + + const globalCardList: CardList = {card1: card}; + + const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + + expect(result.personalCard.shouldShowRBR).toBe(true); + expect(result.personalCard.isFeedConnectionBroken).toBe(true); + }); + + // Past the grace period we suppress ONLY the broken-connection error, not other actionable errors on the same + // card — e.g. a failed reimbursable/start-date update, which lands in a different errorFields entry. + it('should still show the RBR for a past-grace broken card that also has an unrelated field error', () => { + const card = createCard({ + cardID: CARD_IDS.card1, + lastScrapeResult: 403, + lastScrape: '2020-01-01 00:00:00', + errorFields: { + lastScrape: {error: 'Your card connection is broken.'}, + reimbursable: {error: 'Failed to update the reimbursable setting.'}, + }, + }); + + const globalCardList: CardList = {card1: card}; + + const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + + expect(result.personalCard.shouldShowRBR).toBe(true); + }); + + // ...nor an error written to card.errors, e.g. a failed remove of the card. + it('should still show the RBR for a past-grace broken card that also has a card-level error', () => { + const card = createCard({ + cardID: CARD_IDS.card1, + lastScrapeResult: 403, + lastScrape: '2020-01-01 00:00:00', + errorFields: {lastScrape: {error: 'Your card connection is broken.'}}, + errors: {removeFailed: 'Failed to remove the card.'}, + }); + + const globalCardList: CardList = {card1: card}; + + const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + + expect(result.personalCard.shouldShowRBR).toBe(true); + }); + + it('should still show the RBR for a personal card with an unrelated error and a healthy connection', () => { + const card = createCard({ + cardID: CARD_IDS.card1, + lastScrapeResult: 200, // Not a broken connection, so the grace period does not apply + lastScrape: '2020-01-01 00:00:00', + errors: {unrelatedError: 'Something else went wrong.'}, + }); + + const globalCardList: CardList = {card1: card}; + + const result = cardFeedErrorsConfig.compute([globalCardList, {}, {}, undefined], DERIVED_VALUE_CONTEXT); + + expect(result.personalCard.shouldShowRBR).toBe(true); + }); }); describe('processing cards from workspace cards collection', () => {