diff --git a/src/CONST/index.ts b/src/CONST/index.ts index efdf5a520fd0..7986fb01cc03 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8364,6 +8364,7 @@ const CONST = { BULK_ACTIONS_DROPDOWN: 'WorkspaceRules-BulkActionsDropdown', }, EXPENSIFY_CARD: { + ROW: 'WorkspaceExpensifyCard-Row', ISSUE_CARD_BUTTON: 'WorkspaceExpensifyCard-IssueCardButton', MORE_DROPDOWN: 'WorkspaceExpensifyCard-MoreDropdown', CHOOSE_SPEND_RULE: 'WorkspaceExpensifyCard-ChooseSpendRule', diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 1ed2c932676d..883596b95363 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -1,6 +1,6 @@ import {FlashList} from '@shopify/flash-list'; import React from 'react'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -66,6 +66,8 @@ function TableBody({contentContainerStyle, style, .. addOfflineIndicatorBottomSafeAreaPadding: true, style: shouldUseNarrowTableLayout ? styles.pb20 : styles.pb4, }); + const {minHeight: contentMinHeight} = StyleSheet.flatten(contentContainerStyle) ?? {}; + const {paddingBottom: tableBodyBottomPadding} = StyleSheet.flatten(tableBodyContentContainerStyle) ?? {}; // Determine the message based on what caused the empty result const getEmptyMessage = () => { @@ -105,7 +107,15 @@ function TableBody({contentContainerStyle, style, .. showsVerticalScrollIndicator={false} maintainVisibleContentPosition={{disabled: true}} ListEmptyComponent={isEmptyResult ? EmptyResultComponent : ListEmptyComponent} - contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flexGrow1, listContentContainerStyle, tableBodyContentContainerStyle, contentContainerStyle]} + contentContainerStyle={[ + filteredAndSortedData.length === 0 && styles.flexGrow1, + listContentContainerStyle, + tableBodyContentContainerStyle, + contentContainerStyle, + shouldUseNarrowTableLayout && + typeof contentMinHeight === 'number' && + typeof tableBodyBottomPadding === 'number' && {minHeight: contentMinHeight + tableBodyBottomPadding}, + ]} keyboardShouldPersistTaps="handled" {...restListProps} /> diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 6fad4474fc0c..ef5b75a212f4 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -47,6 +47,9 @@ type TableRowProps = Omit & { /** Custom element to render in place of the selection checkbox (e.g. a lock icon for non-selectable rows) */ checkboxReplacementElement?: React.ReactNode; + + /** Optional content rendered below the row grid */ + rowFooter?: React.ReactNode; }; export default function TableRow({ @@ -62,6 +65,7 @@ export default function TableRow({ onPress, offlineWithFeedback, checkboxReplacementElement, + rowFooter, ...props }: TableRowProps) { useSkeletonSpan('TableRowSkeleton', skeletonReasonAttributes); @@ -249,6 +253,8 @@ export default function TableRow({ )} + {rowFooter} + {!!offlineWithFeedback?.errors && ( + + + {frozenByText} + + + ); + + return ( + + {({hovered}) => ( + <> + + + + + {shouldUseNarrowTableLayout ? ( + + ) : ( + + )} + + + + {!shouldUseNarrowTableLayout && ( + + + + )} + + {!shouldUseNarrowTableLayout && ( + + + + )} + + {!shouldUseNarrowTableLayout && ( + + + + )} + + + + {shouldUseNarrowTableLayout && ( + + {cardType} + + )} + + + + + + + )} + + ); +} diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx new file mode 100644 index 000000000000..c3764c02805d --- /dev/null +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -0,0 +1,224 @@ +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import React from 'react'; +import type {ReactElement} from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Table from '@components/Table'; +import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {filterCardsByPersonalDetails, getTranslationKeyForLimitType} from '@libs/CardUtils'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; +import WorkspaceCardListLabels from '@pages/workspace/expensifyCard/WorkspaceCardListLabels'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {Card, PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; +import type {CardLimitType} from '@src/types/onyx/Card'; +import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; +import type {ExpensifyCardSettingsBase} from '@src/types/onyx/ExpensifyCardSettings'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import WorkspaceExpensifyCardsTableRow from './WorkspaceExpensifyCardsTableRow'; + +type WorkspaceExpensifyCardTableColumnKey = 'name' | 'type' | 'limitType' | 'lastFour' | 'limit' | 'actions'; + +type WorkspaceExpensifyCardTableRowData = TableData & { + cardID: number; + card: Card; + lastFourPAN: string; + name: string; + cardholder?: PersonalDetails | null; + limit: number; + currency?: string; + isVirtual: boolean; + limitType: CardLimitType | undefined; + frozenByDisplayName?: string; + frozenByAccountID?: number; + frozenDate?: string; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; + action: () => void; + onClose: () => void; +}; + +type WorkspaceExpensifyCardsTableProps = { + /** Policy ID */ + policyID: string; + + /** List of Expensify cards to display in the table */ + cards: WorkspaceExpensifyCardTableRowData[]; + + /** Whether multi selection is enabled */ + selectionEnabled: boolean; + + /** The list of selected keys for the table */ + selectedKeys: string[]; + + /** Callback when row selection changes */ + onRowSelectionChange: (selectedRowKeys: string[]) => void; + + /** Card settings used to display labels and top-level errors */ + cardSettings?: ExpensifyCardSettings; + + /** Base card settings used to display labels */ + cardSettingsBase?: ExpensifyCardSettingsBase; + + /** Personal details used for search filtering */ + personalDetails?: PersonalDetailsList; + + /** Optional footer component rendered at the bottom of the scrollable list */ + listFooterComponent?: ReactElement; + + /** Optional styles for the list footer component */ + listFooterComponentStyle?: StyleProp; + + /** Optional styles for the list content container */ + listContentContainerStyle?: StyleProp; +}; + +export default function WorkspaceExpensifyCardsTable({ + policyID, + cards, + selectionEnabled, + selectedKeys, + onRowSelectionChange, + cardSettings, + cardSettingsBase, + personalDetails, + listFooterComponent, + listFooterComponentStyle, + listContentContainerStyle, +}: WorkspaceExpensifyCardsTableProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + + const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; + const errorMessage = getLatestErrorMessage(cardSettings) ?? ''; + + const columns: Array> = [ + { + key: 'name', + label: translate('workspace.expensifyCard.name'), + sortable: true, + }, + { + key: 'type', + label: translate('common.type'), + sortable: true, + }, + { + key: 'limitType', + label: translate('workspace.card.issueNewCard.limitType'), + sortable: true, + }, + { + key: 'lastFour', + label: translate('workspace.expensifyCard.lastFour'), + sortable: true, + }, + { + key: 'limit', + label: translate('workspace.expensifyCard.limit'), + sortable: true, + styling: { + containerStyles: [styles.justifyContentEnd], + }, + }, + { + key: 'actions', + label: '', + sortable: false, + width: variables.tableCaretColumnWidth, + }, + ]; + + const compareItems: CompareItemsCallback = (item1, item2, activeSorting) => { + const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; + + if (activeSorting.columnKey === 'type') { + const type1 = item1.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'); + const type2 = item2.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'); + return localeCompare(type1, type2) * orderMultiplier; + } + + if (activeSorting.columnKey === 'limitType') { + const limitType1 = translate(getTranslationKeyForLimitType(item1.limitType)); + const limitType2 = translate(getTranslationKeyForLimitType(item2.limitType)); + return localeCompare(limitType1, limitType2) * orderMultiplier; + } + + if (activeSorting.columnKey === 'lastFour') { + return localeCompare(item1.lastFourPAN, item2.lastFourPAN) * orderMultiplier; + } + + if (activeSorting.columnKey === 'limit') { + return (item1.limit - item2.limit) * orderMultiplier; + } + + const cardholderName1 = item1.cardholder?.displayName ?? item1.cardholder?.login ?? ''; + const cardholderName2 = item2.cardholder?.displayName ?? item2.cardholder?.login ?? ''; + return localeCompare(cardholderName1, cardholderName2) * orderMultiplier; + }; + + const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => filterCardsByPersonalDetails(item.card, searchValue, personalDetails); + + const renderCardItem = ({item, index}: ListRenderItemInfo) => ( + + ); + + const cardListHeaderContent = ( + <> + + + {!!errorMessage && ( + + + + )} + + {cards.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( + + )} + + + ); + + return ( + item.keyForList} + selectionEnabled={selectionEnabled} + selectedKeys={selectedKeys} + onRowSelectionChange={onRowSelectionChange} + ListFooterComponent={listFooterComponent} + ListFooterComponentStyle={listFooterComponentStyle} + ListHeaderComponent={cardListHeaderContent} + > + +
+ ); +} + +export type {WorkspaceExpensifyCardTableRowData, WorkspaceExpensifyCardTableColumnKey}; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx deleted file mode 100644 index 5f539d0283bc..000000000000 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import Checkbox from '@components/Checkbox'; -import FormHelpMessage from '@components/FormHelpMessage'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {getLatestErrorMessage} from '@libs/ErrorUtils'; -import type {ExpensifyCardSettings} from '@src/types/onyx'; - -type WorkspaceCardListHeaderProps = { - /** Card settings */ - cardSettings: ExpensifyCardSettings | undefined; - - /** When set, shows a select-all control aligned with card row checkboxes */ - bulkSelection?: { - onSelectAll: () => void; - isSelectAllChecked: boolean; - isSelectAllIndeterminate: boolean; - }; -}; - -function WorkspaceCardListHeader({cardSettings, bulkSelection}: WorkspaceCardListHeaderProps) { - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {shouldUseNarrowLayout, isMediumScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const isLessThanMediumScreen = isMediumScreenWidth || isSmallScreenWidth; - - const errorMessage = getLatestErrorMessage(cardSettings) ?? ''; - - return ( - - {!!errorMessage && ( - - - - )} - - {!!bulkSelection && ( - - - - )} - - - {translate('workspace.expensifyCard.name')} - - - {!shouldUseNarrowLayout && ( - - - {translate('common.type')} - - - )} - {!shouldUseNarrowLayout && ( - - - {translate('workspace.card.issueNewCard.limitType')} - - - )} - - - {translate('workspace.expensifyCard.lastFour')} - - - - - {translate('workspace.expensifyCard.limit')} - - - - - ); -} - -export default WorkspaceCardListHeader; diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx deleted file mode 100644 index 9c86c0b15902..000000000000 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import React, {useMemo} from 'react'; -import {View} from 'react-native'; -import Avatar from '@components/Avatar'; -import Checkbox from '@components/Checkbox'; -import Icon from '@components/Icon'; -import {useSession} from '@components/OnyxListItemProvider'; -import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {getTranslationKeyForLimitType} from '@libs/CardUtils'; -import {convertToShortDisplayString} from '@libs/CurrencyUtils'; -import DateUtils from '@libs/DateUtils'; -import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import CONST from '@src/CONST'; -import type {PersonalDetails} from '@src/types/onyx'; -import type {CardLimitType} from '@src/types/onyx/Card'; - -type WorkspacesListRowProps = { - /** The last four digits of the card */ - lastFourPAN: string; - - /** Card name */ - name: string; - - /** Cardholder personal details */ - cardholder?: PersonalDetails | null; - - /** Display name of the user who froze the card */ - frozenByDisplayName?: string; - - /** AccountID of the user who froze the card */ - frozenByAccountID?: number; - - /** Date when the card was frozen */ - frozenDate?: string; - - /** Card limit */ - limit: number; - - /** Policy currency */ - currency?: string; - - /** Type of card */ - isVirtual: boolean; - - /** Whether the list item is hovered */ - isHovered?: boolean; - - /** Card limit type */ - limitType: CardLimitType | undefined; - - /** When set, shows a row checkbox for bulk selection */ - bulkSelection?: { - isSelected: boolean; - onToggle: () => void; - }; -}; - -function WorkspaceCardListRow({ - limit, - cardholder, - lastFourPAN, - name, - frozenByDisplayName, - frozenByAccountID, - frozenDate, - currency, - isVirtual, - isHovered, - limitType, - bulkSelection, -}: WorkspacesListRowProps) { - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FallbackAvatar', 'FreezeCard']); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const theme = useTheme(); - const session = useSession(); - const cardholderName = useMemo(() => getDisplayNameOrDefault(cardholder), [cardholder]); - const cardType = isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'); - const formattedFrozenDate = frozenDate ? DateUtils.formatWithUTCTimeZone(frozenDate, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT) : ''; - const frozenByAdminPrefix = translate('cardPage.frozenByAdminPrefix', {date: formattedFrozenDate}); - const frozenByText = useMemo(() => { - if (!formattedFrozenDate) { - return undefined; - } - - if (frozenByAccountID === session?.accountID) { - return translate('cardPage.youFroze', {date: formattedFrozenDate}); - } - - return `${frozenByAdminPrefix}${frozenByDisplayName ?? translate('common.someone')}`; - }, [formattedFrozenDate, frozenByAccountID, frozenByAdminPrefix, frozenByDisplayName, session?.accountID, translate]); - - return ( - - - {!!bulkSelection && ( - - - - )} - - - - - {cardholderName} - - - {name} - - - - {!shouldUseNarrowLayout && ( - - - {cardType} - - - )} - {!shouldUseNarrowLayout && ( - - - {translate(getTranslationKeyForLimitType(limitType))} - - - )} - - - {lastFourPAN} - - - - - {convertToShortDisplayString(limit, currency)} - - {shouldUseNarrowLayout && ( - - {cardType} - - )} - - - - - - {!!frozenByText && ( - - - {frozenByText} - - )} - - ); -} - -export default WorkspaceCardListRow; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index b4c51cdd400c..af70f969a116 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useMemo, useState} from 'react'; -import type {ListRenderItemInfo} from 'react-native'; -import {FlatList, View} from 'react-native'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; @@ -10,30 +9,30 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import FeedSelector from '@components/FeedSelector'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import SearchBar from '@components/SearchBar'; +import type {WorkspaceExpensifyCardTableRowData} from '@components/Tables/WorkspaceExpensifyCardsTable'; +import WorkspaceExpensifyCardsTable from '@components/Tables/WorkspaceExpensifyCardsTable'; import Text from '@components/Text'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; import useDefaultFundID from '@hooks/useDefaultFundID'; import useEmptyViewHeaderHeight from '@hooks/useEmptyViewHeaderHeight'; import useExpensifyCardFeedsForFeedSelector from '@hooks/useExpensifyCardFeedsForFeedSelector'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchResults from '@hooks/useSearchResults'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearIssueNewCardFormData, exportExpensifyCardListToCSV, setIssueNewCardStepAndData} from '@libs/actions/Card'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearDeletePaymentMethodError} from '@libs/actions/PaymentMethods'; -import {filterCardsByPersonalDetails, getCardsByCardholderName, getCardSettings, isCurrencySupportedForECards, sortCardsByCardholderName} from '@libs/CardUtils'; +import {getCardsByCardholderName, getCardSettings, isCurrencySupportedForECards} from '@libs/CardUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -46,11 +45,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Card, WorkspaceCardsList} from '@src/types/onyx'; +import type {WorkspaceCardsList} from '@src/types/onyx'; import EmptyCardView from './EmptyCardView'; -import WorkspaceCardListHeader from './WorkspaceCardListHeader'; -import WorkspaceCardListLabels from './WorkspaceCardListLabels'; -import WorkspaceCardListRow from './WorkspaceCardListRow'; type WorkspaceExpensifyCardListPageProps = { /** Route from navigation */ @@ -66,9 +62,10 @@ type WorkspaceExpensifyCardListPageProps = { function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExpensifyCardListPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Export', 'Gear', 'Plus']); const {shouldUseNarrowLayout, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['HandCard', 'ExpensifyCardImage']); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); const policyID = route.params.policyID; const policy = usePolicy(policyID); const defaultFundID = useDefaultFundID(policyID); @@ -85,8 +82,8 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); - const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; const isBankAccountVerified = !cardOnWaitlist; + const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; const {windowHeight} = useWindowDimensions(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const {canWrite: canWriteExpensifyCard, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD); @@ -107,39 +104,54 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp }, [cardsList, policy?.employeeList]); const isCardListEmpty = allCards.length === 0; - const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); - const sortCards = useCallback((cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare), [personalDetails, localeCompare]); - const [inputValue, setInputValue, filteredSortedCards] = useSearchResults(allCards, filterCard, sortCards); - const [selectedCardIDs, setSelectedCardIDs] = useState([]); - const selectableCardIDs = filteredSortedCards.map((card) => card.cardID); + const [selectedCardKeys, setSelectedCardKeys] = useState([]); + const selectableCardKeySet = useMemo(() => new Set(allCards.map((card) => String(card.cardID))), [allCards]); + const validatedSelectedCardKeys = useMemo(() => selectedCardKeys.filter((key) => selectableCardKeySet.has(key)), [selectedCardKeys, selectableCardKeySet]); + const selectedCardIDs = useMemo(() => validatedSelectedCardKeys.map((key) => Number(key)), [validatedSelectedCardKeys]); + + const clearTableSelection = useCallback(() => { + setSelectedCardKeys((prevSelectedCardKeys) => (prevSelectedCardKeys.length > 0 ? [] : prevSelectedCardKeys)); + }, []); + + useCleanupSelectedOptions(clearTableSelection); + + const cardRows = useMemo( + () => + allCards.map((card) => { + const frozenByDisplayName = card.nameValuePairs?.frozen?.byAccountID + ? getDisplayNameOrDefault(personalDetails?.[card.nameValuePairs.frozen.byAccountID], '', false) || undefined + : undefined; + + return { + keyForList: String(card.cardID), + cardID: card.cardID, + card, + lastFourPAN: card.lastFourPAN ?? '', + name: card.nameValuePairs?.cardTitle ?? '', + cardholder: personalDetails?.[card.accountID ?? CONST.DEFAULT_NUMBER_ID], + limit: card.nameValuePairs?.unapprovedExpenseLimit ?? 0, + currency: settlementCurrency, + isVirtual: !!card.nameValuePairs?.isVirtual, + limitType: card.nameValuePairs?.limitType, + frozenByDisplayName, + frozenByAccountID: card.nameValuePairs?.frozen?.byAccountID, + frozenDate: card.nameValuePairs?.frozen?.date, + errors: card.errors, + pendingAction: card.pendingAction, + action: () => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_DETAILS.getRoute(policyID, card.cardID.toString())), + onClose: () => clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, card.cardID), + }; + }), + [allCards, defaultFundID, personalDetails, policyID, settlementCurrency], + ); - const prunedSelectedCardIDs = selectedCardIDs.filter((id) => selectableCardIDs.includes(id)); - if (prunedSelectedCardIDs.length !== selectedCardIDs.length) { - setSelectedCardIDs(prunedSelectedCardIDs); - } - const toggleCardSelection = (cardID: number) => { - setSelectedCardIDs((prev) => (prev.includes(cardID) ? prev.filter((id) => id !== cardID) : [...prev, cardID])); - }; - const toggleSelectAll = () => { - if (selectableCardIDs.length === 0) { - return; - } - setSelectedCardIDs((prev) => { - if (prev.length > 0) { - return []; - } - return [...selectableCardIDs]; - }); - }; - const isSelectAllChecked = selectedCardIDs.length > 0 && selectedCardIDs.length === selectableCardIDs.length; - const isSelectAllIndeterminate = selectedCardIDs.length > 0 && selectedCardIDs.length < selectableCardIDs.length; const bulkExportOptions: Array> = [ { icon: icons.Export, text: translate('workspace.expensifyCard.exportAsCSV'), value: CONST.EXPENSIFY_CARD.BULK_ACTIONS.EXPORT_CSV, onSelected: () => { - const selectedCards = filteredSortedCards.filter((card) => selectedCardIDs.includes(card.cardID)); + const selectedCards = cardRows.filter((row) => validatedSelectedCardKeys.includes(row.keyForList)).map((row) => row.card); exportExpensifyCardListToCSV({ policyID, cards: selectedCards, @@ -186,7 +198,9 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp shouldShowSelector && shouldDisplayButtonsInSeparateLine && styles.mt3, ]; - if (selectedCardIDs.length > 0) { + const shouldShowBulkSelectionDropdown = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : selectedCardIDs.length > 0; + + if (shouldShowBulkSelectionDropdown) { return ( @@ -196,6 +210,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp options={bulkExportOptions} isSplitButton={false} shouldAlwaysShowDropdownMenu + isDisabled={!selectedCardIDs.length} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE_EXPENSIFY_CARD.BULK_ACTIONS_DROPDOWN} wrapperStyle={[!isInLandscapeMode && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.flexShrink1]} /> @@ -233,102 +248,30 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp ); }; - const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; - const shouldShowBulkSelection = filteredSortedCards.length > 0; - - const renderItem = ({item, index}: ListRenderItemInfo) => { - const frozenByDisplayName = item.nameValuePairs?.frozen?.byAccountID - ? getDisplayNameOrDefault(personalDetails?.[item.nameValuePairs.frozen.byAccountID], '', false) || undefined - : undefined; - - const isCardSelected = selectedCardIDs.includes(item.cardID); - - return ( - clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, item.cardID)} - > - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_DETAILS.getRoute(policyID, item.cardID.toString()))} - > - {({hovered}) => ( - toggleCardSelection(item.cardID), - } - : undefined - } - /> - )} - - - ); - }; - - const renderListHeader = ( - <> - - - {allCards.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( - - )} - - {!isSearchEmpty && ( - - )} - - ); - const handleBackButtonPress = () => { + if (isMobileSelectionModeEnabled) { + clearTableSelection(); + turnOffMobileSelectionMode(); + return true; + } + Navigation.goBack(); return true; }; - const shouldShowHeaderButtons = selectedCardIDs.length > 0 || canWriteExpensifyCard || !isCardListEmpty; + const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; + const shouldShowHeaderButtons = (shouldUseNarrowLayout && isMobileSelectionModeEnabled) || selectedCardIDs.length > 0 || canWriteExpensifyCard || !isCardListEmpty; useAndroidBackButtonHandler(handleBackButtonPress); + const disclaimerFooter = ( + setFooterHeight(event.nativeEvent.layout.height)} + > + {translate(shouldShowEuUkDisclaimer ? 'workspace.expensifyCard.euUkDisclaimer' : 'workspace.expensifyCard.disclaimer')} + + ); + return ( ) : ( - - setFooterHeight(event.nativeEvent.layout.height)} - > - {translate(shouldShowEuUkDisclaimer ? 'workspace.expensifyCard.euUkDisclaimer' : 'workspace.expensifyCard.disclaimer')} - - } - ListFooterComponentStyle={[styles.flexGrow1, styles.justifyContentEnd]} - keyboardShouldPersistTaps="handled" + + 0} + selectedKeys={validatedSelectedCardKeys} + onRowSelectionChange={setSelectedCardKeys} + cardSettings={cardSettings} + cardSettingsBase={settings} + personalDetails={personalDetails} + listFooterComponent={disclaimerFooter} + listFooterComponentStyle={[styles.flexGrow1, styles.justifyContentEnd]} + listContentContainerStyle={[styles.flexGrow1, {minHeight: windowHeight - headerHeight + footerHeight}]} /> - + )} );