diff --git a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx index 66ec8af37bc8..116eef032b6b 100644 --- a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx +++ b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx @@ -47,7 +47,7 @@ function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel, onClose } function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { - const feedValue = useFilterFeedValue(value as string[]); + const feedValue = useFilterFeedValue(Array.isArray(value) ? value : [value]); return ( = TItem & { - flatIndex: number; type: typeof CONST.SECTION_LIST_ITEM_TYPE.ROW; /** Unique key for FlashList rendering, containing section info */ flatListKey: string; diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 100b81b746cd..50e0a5d9bb42 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -3,6 +3,8 @@ import type {FlattenedItem, Section, SectionListItem} from '@components/Selectio import CONST from '@src/CONST'; +import type {TupleToUnion} from 'type-fest'; + import {useMemo} from 'react'; function isItemSelected(item: TItem): boolean { @@ -15,26 +17,32 @@ function isItemSelected(item: TItem): boolean { * Selected items remain interactive even when marked as disabled. */ function shouldTreatItemAsDisabled(item: TItem | FlattenedItem): boolean { - return !!item?.isDisabled && !isItemSelected(item as TItem); + return !!item?.isDisabled && !('isSelected' in item && isItemSelected(item)); } -type UseFlattenedSectionsResult = { - flattenedData: Array>; +type UseFlattenedSectionsResultGeneric = { + flattenedData: Array>; disabledIndexes: number[]; itemsCount: number; - selectedItems: ListItem[]; + selectedItems: TItem[]; initialFocusedIndex: number; firstFocusableIndex: number; }; +type UseFlattenedSections = (sections: Array>, initiallyFocusedItemKey?: string | null) => UseFlattenedSectionsResultGeneric; + /** - * Non-generic implementation so OXC's React Compiler can memoize the hook. - * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). + * Hook that flattens sections with headers and items into a single array for FlashList. + * Also computes disabled indexes, selected items, and initial focus index. + * The contextual generic keeps item provenance without declaring type params inside the hook, + * which OXC's React Compiler cannot hoist. */ -function useFlattenedSectionsImpl(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult { +const useFlattenedSections: UseFlattenedSections = (sections, initiallyFocusedItemKey) => { return useMemo(() => { - const data: Array> = []; - const selectedOptions: ListItem[] = []; + type Item = TupleToUnion['data'][number]; + + const data: Array> = []; + const selectedOptions: Item[] = []; const disabledIndices: number[] = []; let focusedIndex = -1; let firstNonHeaderIndex = -1; @@ -58,12 +66,12 @@ function useFlattenedSectionsImpl(sections: Array>, initiallyF for (const item of section.data ?? []) { const currentIndex = data.length; - const itemData = { + const itemData: SectionListItem = { ...item, type: CONST.SECTION_LIST_ITEM_TYPE.ROW, isDisabled: section.isDisabled === true || item.isDisabled === true, flatListKey: `${section.sectionIndex}-${item.keyForList}`, - } as SectionListItem; + }; data.push(itemData); if (firstNonHeaderIndex === -1) { @@ -94,24 +102,7 @@ function useFlattenedSectionsImpl(sections: Array>, initiallyF firstFocusableIndex: firstNonHeaderIndex === -1 ? 0 : firstNonHeaderIndex, }; }, [initiallyFocusedItemKey, sections]); -} - -type UseFlattenedSectionsResultGeneric = { - flattenedData: Array>; - disabledIndexes: number[]; - itemsCount: number; - selectedItems: TItem[]; - initialFocusedIndex: number; - firstFocusableIndex: number; }; -/** - * Hook that flattens sections with headers and items into a single array for FlashList. - * Also computes disabled indexes, selected items, and initial focus index. - */ -function useFlattenedSections(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResultGeneric { - return useFlattenedSectionsImpl(sections as Array>, initiallyFocusedItemKey) as UseFlattenedSectionsResultGeneric; -} - export default useFlattenedSections; export {isItemSelected, shouldTreatItemAsDisabled}; diff --git a/src/hooks/useBulkPayOptions.ts b/src/hooks/useBulkPayOptions.ts index ca4f51130408..7f28baab3b9b 100644 --- a/src/hooks/useBulkPayOptions.ts +++ b/src/hooks/useBulkPayOptions.ts @@ -1,7 +1,6 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {BankAccountMenuItem} from '@components/Search/types'; -import {isCurrencySupportedForGlobalReimbursement} from '@libs/actions/Policy/Policy'; import {isBankAccountPartiallySetup} from '@libs/BankAccountUtils'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getBusinessBankAccountOptions, matchesCurrency} from '@libs/PaymentUtils'; @@ -19,9 +18,6 @@ import useSettlementButtonPaymentMethods from '@libs/SettlementButtonUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {AccountData} from '@src/types/onyx'; - -import type {TupleToUnion} from 'type-fest'; import {areInvoicesEnabledSelector} from '@selectors/Policy'; import truncate from 'lodash/truncate'; @@ -34,8 +30,6 @@ import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; import useThemeStyles from './useThemeStyles'; -type CurrencyType = TupleToUnion; - type UseBulkPayOptionProps = { selectedPolicyID: string | undefined; selectedReportID: string | undefined; @@ -96,7 +90,10 @@ function useBulkPayOptions({ const requiredAccountType = payAsBusiness ? CONST.BANK_ACCOUNT.TYPE.BUSINESS : CONST.BANK_ACCOUNT.TYPE.PERSONAL; return formattedPaymentMethods .filter((method) => { - const accountData = method?.accountData as AccountData; + if (!('bankCurrency' in method)) { + return false; + } + const accountData = method.accountData; const isPartiallySetup = isBankAccountPartiallySetup(accountData?.state); return accountData?.type === requiredAccountType && !isPartiallySetup && matchesCurrency(method, currency); }) @@ -128,7 +125,7 @@ function useBulkPayOptions({ value: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, })) : undefined; - const personalBankAccountList = formattedPaymentMethods.filter((ba) => (ba.accountData as AccountData)?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL); + const personalBankAccountList = formattedPaymentMethods.filter((method) => 'bankCurrency' in method && method.accountData?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL); let bulkPayButtonOptions; if (!selectedReportID || !selectedPolicyID) { @@ -196,7 +193,7 @@ function useBulkPayOptions({ } if (isInvoiceReport) { - const showPayViaExpensifyOptions = isPayInvoiceViaExpensifyBetaEnabled && isCurrencySupportedForGlobalReimbursement(currency as CurrencyType); + const showPayViaExpensifyOptions = isPayInvoiceViaExpensifyBetaEnabled && CONST.DIRECT_REIMBURSEMENT_CURRENCIES.some((supportedCurrency) => supportedCurrency === currency); const getInvoicesOptions = (payAsBusiness: boolean) => { const addBankAccountItem = { text: translate('bankAccount.addBankAccount'), diff --git a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx index fdefea8b7b02..c74431c76273 100644 --- a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx +++ b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx @@ -154,8 +154,12 @@ function PlaidConnectionStep({feed, onExit}: {feed?: CompanyCardFeedWithDomainID // on success we need to move to bank connection screen with token, bank name = plaid Log.info('[PlaidLink] Success!'); - const plaidConnectedFeed = (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.institution_id ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.id; - const plaidConnectedFeedName = (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.name ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.name; + const institution = metadata.institution; + let plaidConnectedFeed: string | undefined; + if (institution) { + plaidConnectedFeed = 'institution_id' in institution ? institution.institution_id : institution.id; + } + const plaidConnectedFeedName = institution?.name; setAddNewPersonalCardStepAndData({ step: CONST.PERSONAL_CARDS.STEP.BANK_CONNECTION, diff --git a/tests/unit/Search/SearchFilterBarTest.tsx b/tests/unit/Search/SearchFilterBarTest.tsx new file mode 100644 index 000000000000..f2e893aa25a6 --- /dev/null +++ b/tests/unit/Search/SearchFilterBarTest.tsx @@ -0,0 +1,93 @@ +import {render, screen} from '@testing-library/react-native'; + +import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; +import SearchFilterBar from '@components/Search/SearchPageHeader/SearchFilterBar'; +import type {FilterItem} from '@components/Search/SearchPageHeader/useSearchFiltersBar'; +import Text from '@components/Text'; + +import type {SearchFilter} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, CardList} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../../utils/createMock'; + +const FIRST_CARD: Card = { + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardID: 123, + domainName: 'first-card.example', + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastUpdated: '', + nameValuePairs: createMock>({cardTitle: 'First card'}), + state: CONST.EXPENSIFY_CARD.STATE.OPEN, +}; +const SECOND_CARD: Card = { + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardID: 456, + domainName: 'second-card.example', + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastUpdated: '', + nameValuePairs: createMock>({cardTitle: 'Second card'}), + state: CONST.EXPENSIFY_CARD.STATE.OPEN, +}; + +const CARD_LIST: CardList = { + [FIRST_CARD.cardID]: FIRST_CARD, + [SECOND_CARD.cardID]: SECOND_CARD, +}; + +jest.mock('@components/Search/FilterDropdowns/DropdownButton', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +const mockDropdownButton = jest.mocked(DropdownButton); + +function createCardFilter(value: string): SearchFilter & FilterItem { + return { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + label: 'Cards', + value, + PopoverComponent: () => null, + sentryLabel: 'Search-Filter-cardID', + onClosePress: jest.fn(), + }; +} + +describe('SearchFilterBar card descriptions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockDropdownButton.mockImplementation(({label, value}) => { + const selectedItems = Array.isArray(value) ? value.join(', ') : value; + return {selectedItems ? `${label}: ${selectedItems}` : label}; + }); + + await Onyx.clear(); + await Onyx.set(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST, CARD_LIST); + }); + + it('renders the description for one scalar card ID', () => { + render(); + + expect(screen.getByText('Cards: First card')).toBeOnTheScreen(); + }); + + it('renders both descriptions for joined scalar card IDs in card-list order', () => { + render(); + + expect(screen.getByText('Cards: First card, Second card')).toBeOnTheScreen(); + }); +});