Skip to content

Commit 8ed5bc1

Browse files
authored
Merge pull request Expensify#81363 from Expensify/vit-fixShippingCard
[Bug] Fix RHP not closing after shipping Expensify Card
2 parents 42c19be + ec6148f commit 8ed5bc1

3 files changed

Lines changed: 95 additions & 10 deletions

File tree

src/pages/MissingPersonalDetails/MissingPersonalDetailsMagicCodePage.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1+
import {areAllExpensifyCardsShipped} from '@selectors/Card';
12
import React, {useCallback, useEffect, useMemo} from 'react';
2-
import type {OnyxEntry} from 'react-native-onyx';
33
import ValidateCodeActionContent from '@components/ValidateCodeActionModal/ValidateCodeActionContent';
44
import useLocalize from '@hooks/useLocalize';
55
import useOnyx from '@hooks/useOnyx';
66
import {clearDraftValues} from '@libs/actions/FormActions';
77
import {clearPersonalDetailsErrors, updatePersonalDetailsAndShipExpensifyCards} from '@libs/actions/PersonalDetails';
88
import {requestValidateCodeAction, resetValidateActionCodeSent} from '@libs/actions/User';
9-
import {isPersonalCard} from '@libs/CardUtils';
109
import {normalizeCountryCode} from '@libs/CountryUtils';
1110
import {getLatestError} from '@libs/ErrorUtils';
1211
import Navigation from '@libs/Navigation/Navigation';
@@ -16,20 +15,16 @@ import ONYXKEYS from '@src/ONYXKEYS';
1615
import ROUTES from '@src/ROUTES';
1716
import {primaryLoginSelector} from '@src/selectors/Account';
1817
import type {PersonalDetailsForm} from '@src/types/form';
19-
import type {CardList} from '@src/types/onyx';
2018
import {isEmptyObject} from '@src/types/utils/EmptyObject';
2119
import {getSubPageValues} from './utils';
2220

23-
const areAllCardsShippedSelector = (cardList: OnyxEntry<CardList>) =>
24-
Object.values(cardList ?? {})?.every((card) => card?.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED && !isPersonalCard(card));
25-
2621
function MissingPersonalDetailsMagicCodePage() {
2722
const {translate} = useLocalize();
2823
const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {canBeMissing: false});
2924
const [draftValues] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM_DRAFT, {canBeMissing: false});
3025
const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false});
3126

32-
const [areAllCardsShipped] = useOnyx(ONYXKEYS.CARD_LIST, {selector: areAllCardsShippedSelector, canBeMissing: true});
27+
const [areAllCardsShipped] = useOnyx(ONYXKEYS.CARD_LIST, {selector: areAllExpensifyCardsShipped, canBeMissing: true});
3328
const [primaryLogin] = useOnyx(ONYXKEYS.ACCOUNT, {selector: primaryLoginSelector, canBeMissing: true});
3429

3530
const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE, {canBeMissing: true});

src/selectors/Card.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type {OnyxEntry} from 'react-native-onyx';
22
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
33
import {getCardFeedsForDisplay} from '@libs/CardFeedUtils';
4-
import {isCard, isCardHiddenFromSearch, isPersonalCard} from '@libs/CardUtils';
4+
import {isCard, isCardHiddenFromSearch, isExpensifyCard, isPersonalCard} from '@libs/CardUtils';
55
import {filterObject} from '@libs/ObjectUtils';
6+
import CONST from '@src/CONST';
67
import type {CardList, NonPersonalAndWorkspaceCardListDerivedValue} from '@src/types/onyx';
78

89
/**
@@ -42,4 +43,14 @@ const defaultExpensifyCardSelector = (allCards: OnyxEntry<NonPersonalAndWorkspac
4243
*/
4344
const cardByIdSelector = (cardID: string) => (cardList: OnyxEntry<CardList>) => cardList?.[cardID];
4445

45-
export {filterCardsHiddenFromSearch, filterOutPersonalCards, defaultExpensifyCardSelector, cardByIdSelector};
46+
/**
47+
* Checks if all Expensify cards have been shipped (state is not STATE_NOT_ISSUED).
48+
* Only considers valid Expensify cards - ignores personal cards, company cards, and invalid entries.
49+
* Returns true if there are no Expensify cards pending issue, or if there are no Expensify cards at all.
50+
*/
51+
const areAllExpensifyCardsShipped = (cardList: OnyxEntry<CardList>): boolean =>
52+
Object.values(cardList ?? {})
53+
.filter((card) => isCard(card) && isExpensifyCard(card))
54+
.every((card) => card.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED);
55+
56+
export {filterCardsHiddenFromSearch, filterOutPersonalCards, defaultExpensifyCardSelector, cardByIdSelector, areAllExpensifyCardsShipped};

tests/unit/selectors/CardTest.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2-
import {defaultExpensifyCardSelector, filterCardsHiddenFromSearch, filterOutPersonalCards} from '@selectors/Card';
2+
import {areAllExpensifyCardsShipped, defaultExpensifyCardSelector, filterCardsHiddenFromSearch, filterOutPersonalCards} from '@selectors/Card';
33
import type {ValueOf} from 'type-fest';
44
import CONST from '@src/CONST';
55
import type {Card, CardList} from '@src/types/onyx';
@@ -189,6 +189,85 @@ describe('defaultExpensifyCardSelector', () => {
189189
});
190190
});
191191

192+
describe('areAllExpensifyCardsShipped', () => {
193+
it('returns true when cardList is undefined or empty', () => {
194+
expect(areAllExpensifyCardsShipped(undefined)).toBe(true);
195+
expect(areAllExpensifyCardsShipped({})).toBe(true);
196+
});
197+
198+
it('returns true when all Expensify cards are shipped (not STATE_NOT_ISSUED)', () => {
199+
const cardList: CardList = {
200+
'1': createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}),
201+
'2': createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}),
202+
};
203+
expect(areAllExpensifyCardsShipped(cardList)).toBe(true);
204+
});
205+
206+
it('returns false when any Expensify card is in STATE_NOT_ISSUED', () => {
207+
const cardList: CardList = {
208+
'1': createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}),
209+
'2': createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}),
210+
};
211+
expect(areAllExpensifyCardsShipped(cardList)).toBe(false);
212+
});
213+
214+
// CRITICAL: This test ensures the personal cards should not affect the result
215+
it('returns true when Expensify cards are shipped even if user has personal cards', () => {
216+
const personalCard = createRandomCard(1, {bank: CONST.PERSONAL_CARD.BANK_NAME.CSV});
217+
const expensifyCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED});
218+
219+
const cardList: CardList = {
220+
'1': personalCard,
221+
'2': expensifyCard,
222+
};
223+
expect(areAllExpensifyCardsShipped(cardList)).toBe(true);
224+
});
225+
226+
// CRITICAL: This test ensures the company cards should not affect the result
227+
it('returns true when Expensify cards are shipped even if user has company cards', () => {
228+
const companyCard = createRandomCompanyCard(1, {bank: 'vcf'});
229+
const expensifyCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN});
230+
231+
const cardList: CardList = {
232+
'1': companyCard,
233+
'2': expensifyCard,
234+
};
235+
expect(areAllExpensifyCardsShipped(cardList)).toBe(true);
236+
});
237+
238+
it('ignores invalid card entries (missing cardID or bank)', () => {
239+
const validCard = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN});
240+
const invalidCard = {cardID: 2} as Card; // Missing bank
241+
242+
const cardList: CardList = {
243+
'1': validCard,
244+
'2': invalidCard,
245+
};
246+
expect(areAllExpensifyCardsShipped(cardList)).toBe(true);
247+
});
248+
249+
it('returns true when only non-Expensify cards exist', () => {
250+
const cardList: CardList = {
251+
'1': createRandomCompanyCard(1, {bank: 'vcf'}),
252+
'2': createRandomCard(2, {bank: CONST.PERSONAL_CARD.BANK_NAME.CSV}),
253+
};
254+
expect(areAllExpensifyCardsShipped(cardList)).toBe(true);
255+
});
256+
257+
it('returns false when mixing shipped and unshipped Expensify cards with other card types', () => {
258+
const companyCard = createRandomCompanyCard(1, {bank: 'vcf'});
259+
const shippedExpensifyCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN});
260+
const unshippedExpensifyCard = createRandomExpensifyCard(3, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED});
261+
262+
const cardList: CardList = {
263+
'1': companyCard,
264+
'2': shippedExpensifyCard,
265+
'3': unshippedExpensifyCard,
266+
};
267+
expect(areAllExpensifyCardsShipped(cardList)).toBe(false);
268+
});
269+
});
270+
192271
describe('filterOutPersonalCards', () => {
193272
it('should return only cards with a valid fundID', () => {
194273
const cardList: CardList = {

0 commit comments

Comments
 (0)