diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2b5df0ab8409..bf6ceaa2f719 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8361,7 +8361,6 @@ 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 883596b95363..1ed2c932676d 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 {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -66,8 +66,6 @@ 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 = () => { @@ -107,15 +105,7 @@ function TableBody({contentContainerStyle, style, .. showsVerticalScrollIndicator={false} maintainVisibleContentPosition={{disabled: true}} ListEmptyComponent={isEmptyResult ? EmptyResultComponent : ListEmptyComponent} - contentContainerStyle={[ - filteredAndSortedData.length === 0 && styles.flexGrow1, - listContentContainerStyle, - tableBodyContentContainerStyle, - contentContainerStyle, - shouldUseNarrowTableLayout && - typeof contentMinHeight === 'number' && - typeof tableBodyBottomPadding === 'number' && {minHeight: contentMinHeight + tableBodyBottomPadding}, - ]} + contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flexGrow1, listContentContainerStyle, tableBodyContentContainerStyle, contentContainerStyle]} keyboardShouldPersistTaps="handled" {...restListProps} /> diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx deleted file mode 100644 index 7eec2cb586e9..000000000000 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import Avatar from '@components/Avatar'; -import Icon from '@components/Icon'; -import {useSession} from '@components/OnyxListItemProvider'; -import Table from '@components/Table'; -import Text from '@components/Text'; -import TextWithTooltip from '@components/TextWithTooltip'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -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 variables from '@styles/variables'; -import CONST from '@src/CONST'; -import type {WorkspaceExpensifyCardTableRowData} from '.'; - -type WorkspaceExpensifyCardsTableRowProps = { - /** Data about the Expensify card */ - item: WorkspaceExpensifyCardTableRowData; - - /** The index of the row relative to all other rows */ - rowIndex: number; - - /** Whether to use narrow table row layout */ - shouldUseNarrowTableLayout: boolean; -}; - -export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldUseNarrowTableLayout}: WorkspaceExpensifyCardsTableRowProps) { - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FallbackAvatar', 'FreezeCard']); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const theme = useTheme(); - const session = useSession(); - - const cardholderName = getDisplayNameOrDefault(item.cardholder); - const cardType = item.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'); - const limitTypeLabel = translate(getTranslationKeyForLimitType(item.limitType)); - const formattedLimit = convertToShortDisplayString(item.limit, item.currency); - const formattedFrozenDate = item.frozenDate ? DateUtils.formatWithUTCTimeZone(item.frozenDate, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT) : ''; - const frozenByAdminPrefix = translate('cardPage.frozenByAdminPrefix', {date: formattedFrozenDate}); - let frozenByText: string | undefined; - if (formattedFrozenDate) { - frozenByText = - item.frozenByAccountID === session?.accountID - ? translate('cardPage.youFroze', {date: formattedFrozenDate}) - : `${frozenByAdminPrefix}${item.frozenByDisplayName ?? translate('common.someone')}`; - } - - const accessibilityLabel = [cardholderName, item.name, cardType, limitTypeLabel, item.lastFourPAN, formattedLimit, frozenByText].filter(Boolean).join(', '); - - return ( - - {({hovered}) => ( - <> - - - - - {shouldUseNarrowTableLayout ? ( - - ) : ( - - )} - {!!frozenByText && ( - - - {frozenByText} - - )} - - - - {!shouldUseNarrowTableLayout && ( - - - - )} - - {!shouldUseNarrowTableLayout && ( - - - - )} - - {!shouldUseNarrowTableLayout && ( - - - - )} - - - - {shouldUseNarrowTableLayout && ( - - {cardType} - - )} - - - - - - - )} - - ); -} diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx deleted file mode 100644 index c3764c02805d..000000000000 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ /dev/null @@ -1,224 +0,0 @@ -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 new file mode 100644 index 000000000000..5f539d0283bc --- /dev/null +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx @@ -0,0 +1,112 @@ +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 new file mode 100644 index 000000000000..9c86c0b15902 --- /dev/null +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx @@ -0,0 +1,216 @@ +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 af70f969a116..b4c51cdd400c 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; -import {View} from 'react-native'; +import type {ListRenderItemInfo} from 'react-native'; +import {FlatList, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; @@ -9,30 +10,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 type {WorkspaceExpensifyCardTableRowData} from '@components/Tables/WorkspaceExpensifyCardsTable'; -import WorkspaceExpensifyCardsTable from '@components/Tables/WorkspaceExpensifyCardsTable'; +import ScrollView from '@components/ScrollView'; +import SearchBar from '@components/SearchBar'; 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 {getCardsByCardholderName, getCardSettings, isCurrencySupportedForECards} from '@libs/CardUtils'; +import {filterCardsByPersonalDetails, getCardsByCardholderName, getCardSettings, isCurrencySupportedForECards, sortCardsByCardholderName} from '@libs/CardUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -45,8 +46,11 @@ 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 {WorkspaceCardsList} from '@src/types/onyx'; +import type {Card, 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 */ @@ -62,10 +66,9 @@ type WorkspaceExpensifyCardListPageProps = { function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExpensifyCardListPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Export', 'Gear', 'Plus']); const {shouldUseNarrowLayout, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); - const {translate} = useLocalize(); + const {translate, localeCompare} = 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); @@ -82,8 +85,8 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); - const isBankAccountVerified = !cardOnWaitlist; const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; + const isBankAccountVerified = !cardOnWaitlist; const {windowHeight} = useWindowDimensions(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const {canWrite: canWriteExpensifyCard, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD); @@ -104,54 +107,39 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp }, [cardsList, policy?.employeeList]); const isCardListEmpty = allCards.length === 0; - 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 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 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 = cardRows.filter((row) => validatedSelectedCardKeys.includes(row.keyForList)).map((row) => row.card); + const selectedCards = filteredSortedCards.filter((card) => selectedCardIDs.includes(card.cardID)); exportExpensifyCardListToCSV({ policyID, cards: selectedCards, @@ -198,9 +186,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp shouldShowSelector && shouldDisplayButtonsInSeparateLine && styles.mt3, ]; - const shouldShowBulkSelectionDropdown = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : selectedCardIDs.length > 0; - - if (shouldShowBulkSelectionDropdown) { + if (selectedCardIDs.length > 0) { return ( @@ -210,7 +196,6 @@ 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]} /> @@ -248,30 +233,102 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp ); }; - const handleBackButtonPress = () => { - if (isMobileSelectionModeEnabled) { - clearTableSelection(); - turnOffMobileSelectionMode(); - return true; - } + 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 = () => { Navigation.goBack(); return true; }; - const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; - const shouldShowHeaderButtons = (shouldUseNarrowLayout && isMobileSelectionModeEnabled) || selectedCardIDs.length > 0 || canWriteExpensifyCard || !isCardListEmpty; + const shouldShowHeaderButtons = selectedCardIDs.length > 0 || canWriteExpensifyCard || !isCardListEmpty; useAndroidBackButtonHandler(handleBackButtonPress); - const disclaimerFooter = ( - setFooterHeight(event.nativeEvent.layout.height)} - > - {translate(shouldShowEuUkDisclaimer ? 'workspace.expensifyCard.euUkDisclaimer' : 'workspace.expensifyCard.disclaimer')} - - ); - return ( ) : ( - - 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}]} + + setFooterHeight(event.nativeEvent.layout.height)} + > + {translate(shouldShowEuUkDisclaimer ? 'workspace.expensifyCard.euUkDisclaimer' : 'workspace.expensifyCard.disclaimer')} + + } + ListFooterComponentStyle={[styles.flexGrow1, styles.justifyContentEnd]} + keyboardShouldPersistTaps="handled" /> - + )} );