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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/components/Search/SearchPageHeader/SearchFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DropdownButton
label={label}
Expand All @@ -60,7 +60,7 @@ function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress
}

function CardDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
const cardValue = useFilterCardValue(value as string[]);
const cardValue = useFilterCardValue(Array.isArray(value) ? value : value.split(', '));
return (
<DropdownButton
label={label}
Expand All @@ -86,7 +86,7 @@ function BankAccountDropdown({label, value, PopoverComponent, sentryLabel, onClo
}

function TaxRateDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
const taxRateValue = useFilterTaxRateValue(value as string[]);
const taxRateValue = useFilterTaxRateValue(Array.isArray(value) ? value : [value]);
return (
<DropdownButton
label={label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ type SectionHeader = {
};

type SectionListItem<TItem extends ListItem> = TItem & {
flatIndex: number;
type: typeof CONST.SECTION_LIST_ITEM_TYPE.ROW;
/** Unique key for FlashList rendering, containing section info */
flatListKey: string;
Expand Down
47 changes: 19 additions & 28 deletions src/components/SelectionList/hooks/useFlattenedSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TItem extends ListItem>(item: TItem): boolean {
Expand All @@ -15,26 +17,32 @@ function isItemSelected<TItem extends ListItem>(item: TItem): boolean {
* Selected items remain interactive even when marked as disabled.
*/
function shouldTreatItemAsDisabled<TItem extends ListItem>(item: TItem | FlattenedItem<TItem>): boolean {
return !!item?.isDisabled && !isItemSelected(item as TItem);
return !!item?.isDisabled && !('isSelected' in item && isItemSelected(item));
}

type UseFlattenedSectionsResult = {
flattenedData: Array<FlattenedItem<ListItem>>;
type UseFlattenedSectionsResultGeneric<TItem extends ListItem> = {
flattenedData: Array<FlattenedItem<TItem>>;
disabledIndexes: number[];
itemsCount: number;
selectedItems: ListItem[];
selectedItems: TItem[];
initialFocusedIndex: number;
firstFocusableIndex: number;
};

type UseFlattenedSections = <TItem extends ListItem>(sections: Array<Section<TItem>>, initiallyFocusedItemKey?: string | null) => UseFlattenedSectionsResultGeneric<TItem>;

/**
* 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<Section<ListItem>>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult {
const useFlattenedSections: UseFlattenedSections = (sections, initiallyFocusedItemKey) => {
return useMemo(() => {
const data: Array<FlattenedItem<ListItem>> = [];
const selectedOptions: ListItem[] = [];
type Item = TupleToUnion<typeof sections>['data'][number];

const data: Array<FlattenedItem<Item>> = [];
const selectedOptions: Item[] = [];
const disabledIndices: number[] = [];
let focusedIndex = -1;
let firstNonHeaderIndex = -1;
Expand All @@ -58,12 +66,12 @@ function useFlattenedSectionsImpl(sections: Array<Section<ListItem>>, initiallyF

for (const item of section.data ?? []) {
const currentIndex = data.length;
const itemData = {
const itemData: SectionListItem<Item> = {
...item,
type: CONST.SECTION_LIST_ITEM_TYPE.ROW,
isDisabled: section.isDisabled === true || item.isDisabled === true,
flatListKey: `${section.sectionIndex}-${item.keyForList}`,
} as SectionListItem<ListItem>;
};
data.push(itemData);

if (firstNonHeaderIndex === -1) {
Expand Down Expand Up @@ -94,24 +102,7 @@ function useFlattenedSectionsImpl(sections: Array<Section<ListItem>>, initiallyF
firstFocusableIndex: firstNonHeaderIndex === -1 ? 0 : firstNonHeaderIndex,
};
}, [initiallyFocusedItemKey, sections]);
}

type UseFlattenedSectionsResultGeneric<TItem extends ListItem> = {
flattenedData: Array<FlattenedItem<TItem>>;
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<TItem extends ListItem>(sections: Array<Section<TItem>>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResultGeneric<TItem> {
return useFlattenedSectionsImpl(sections as Array<Section<ListItem>>, initiallyFocusedItemKey) as UseFlattenedSectionsResultGeneric<TItem>;
}

export default useFlattenedSections;
export {isItemSelected, shouldTreatItemAsDisabled};
15 changes: 6 additions & 9 deletions src/hooks/useBulkPayOptions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -34,8 +30,6 @@ import useOnyx from './useOnyx';
import usePermissions from './usePermissions';
import useThemeStyles from './useThemeStyles';

type CurrencyType = TupleToUnion<typeof CONST.DIRECT_REIMBURSEMENT_CURRENCIES>;

type UseBulkPayOptionProps = {
selectedPolicyID: string | undefined;
selectedReportID: string | undefined;
Expand Down Expand Up @@ -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);
})
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/Search/SearchFilterBarTest.tsx
Original file line number Diff line number Diff line change
@@ -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<NonNullable<Card['nameValuePairs']>>({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<NonNullable<Card['nameValuePairs']>>({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 <Text>{selectedItems ? `${label}: ${selectedItems}` : label}</Text>;
});

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(<SearchFilterBar item={createCardFilter(FIRST_CARD.cardID.toString())} />);

expect(screen.getByText('Cards: First card')).toBeOnTheScreen();
});

it('renders both descriptions for joined scalar card IDs in card-list order', () => {
render(<SearchFilterBar item={createCardFilter(`${FIRST_CARD.cardID}, ${SECOND_CARD.cardID}`)} />);

expect(screen.getByText('Cards: First card, Second card')).toBeOnTheScreen();
});
});
Loading