From 16ee65fca24fa9c49b33953c4d0ab9ed80c25dbe Mon Sep 17 00:00:00 2001 From: "Nguyen Van Duc (via MelvinBot)" Date: Mon, 19 Oct 2026 02:45:59 +0000 Subject: [PATCH 1/6] Add the Activity list with the active user count to the payment details page Co-authored-by: Nguyen Van Duc --- src/languages/de.ts | 3 + src/languages/el.ts | 3 + src/languages/en.ts | 3 + src/languages/es.ts | 3 + src/languages/fr.ts | 3 + src/languages/it.ts | 3 + src/languages/ja.ts | 3 + src/languages/nl.ts | 3 + src/languages/pl.ts | 3 + src/languages/pt-BR.ts | 3 + src/languages/zh-hans.ts | 3 + src/libs/PaymentHistoryUtils.ts | 6 + .../PaymentActivityListItem.tsx | 22 ++ .../PaymentHistoryPage/PaymentDetailsPage.tsx | 225 ++++++++++++------ tests/ui/PaymentDetailsPageTest.tsx | 136 +++++++++++ tests/unit/PaymentHistoryUtilsTest.ts | 21 ++ 16 files changed, 374 insertions(+), 69 deletions(-) create mode 100644 src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx create mode 100644 tests/ui/PaymentDetailsPageTest.tsx diff --git a/src/languages/de.ts b/src/languages/de.ts index 5dd7a081fe84..b244de1539e4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -11545,6 +11545,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} erstattet am ${date}`, }, billedTo: 'Abgerechnet an', + activity: 'Aktivität', + findActiveUser: 'Aktiven Benutzer suchen', + noActiveUsers: 'In diesem Monat gab es keine aktiven Benutzer.', viewReceipt: 'Beleg anzeigen', viewInvoice: 'Rechnung anzeigen', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 3a0a2f2c3a30..d2ae78ee2655 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -11761,6 +11761,9 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Επιστράφηκαν ${amount} στις ${date}`, }, billedTo: 'Χρέωση σε', + activity: 'Δραστηριότητα', + findActiveUser: 'Εύρεση ενεργού χρήστη', + noActiveUsers: 'Δεν υπήρξαν ενεργοί χρήστες αυτόν τον μήνα.', viewReceipt: 'Προβολή απόδειξης', viewInvoice: 'Προβολή τιμολογίου', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index bb6975f9f669..7e01ddda37fc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -11805,6 +11805,9 @@ const translations = { refundedAmountOn: ({amount, date}: PaymentDetailsPartialRefundParams) => `Refunded ${amount} on ${date}`, }, billedTo: 'Billed to', + activity: 'Activity', + findActiveUser: 'Find an active user', + noActiveUsers: 'No active users this month.', viewReceipt: 'View receipt', viewInvoice: 'View invoice', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 1255ad655989..55970db162d0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -11732,6 +11732,9 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Reembolsado ${amount} el ${date}`, }, billedTo: 'Facturado a', + activity: 'Actividad', + findActiveUser: 'Buscar un usuario activo', + noActiveUsers: 'No hubo usuarios activos este mes.', viewReceipt: 'Ver recibo', viewInvoice: 'Ver factura', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c629e2bd4b0e..119fcf79571d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -11571,6 +11571,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} remboursé le ${date}`, }, billedTo: 'Facturé à', + activity: 'Activité', + findActiveUser: 'Rechercher un utilisateur actif', + noActiveUsers: 'Aucun utilisateur actif ce mois-ci.', viewReceipt: 'Voir le reçu', viewInvoice: 'Voir la facture', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 9db5fb5ac92f..4fb576ebdf83 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -11494,6 +11494,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Rimborsati ${amount} il ${date}`, }, billedTo: 'Fatturato a', + activity: 'Attività', + findActiveUser: 'Trova un utente attivo', + noActiveUsers: 'Nessun utente attivo questo mese.', viewReceipt: 'Visualizza ricevuta', viewInvoice: 'Visualizza fattura', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d412979fa9bc..f99d3bff1f9a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -11328,6 +11328,9 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${date} に ${amount} を返金`, }, billedTo: '請求先', + activity: 'アクティビティ', + findActiveUser: 'アクティブユーザーを検索', + noActiveUsers: '今月のアクティブユーザーはいません。', viewReceipt: '領収書を表示', viewInvoice: '請求書を表示', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 60718020161a..d1e0a46558d3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -11455,6 +11455,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} terugbetaald op ${date}`, }, billedTo: 'Gefactureerd aan', + activity: 'Activiteit', + findActiveUser: 'Zoek een actieve gebruiker', + noActiveUsers: 'Geen actieve gebruikers deze maand.', viewReceipt: 'Bon bekijken', viewInvoice: 'Factuur bekijken', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9792f3bfe001..09498a935e8c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -11476,6 +11476,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Zwrócono ${amount} ${date}`, }, billedTo: 'Obciążono', + activity: 'Aktywność', + findActiveUser: 'Znajdź aktywnego użytkownika', + noActiveUsers: 'W tym miesiącu nie było aktywnych użytkowników.', viewReceipt: 'Wyświetl paragon', viewInvoice: 'Wyświetl fakturę', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ca2cb4c94ba2..157c703c6ae6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -11448,6 +11448,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} reembolsado em ${date}`, }, billedTo: 'Cobrado de', + activity: 'Atividade', + findActiveUser: 'Encontrar um usuário ativo', + noActiveUsers: 'Nenhum usuário ativo neste mês.', viewReceipt: 'Ver recibo', viewInvoice: 'Ver fatura', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 44a092b96b81..a1c7f02768a3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -11035,6 +11035,9 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${date} 已退款 ${amount}`, }, billedTo: '账单对象', + activity: '活动', + findActiveUser: '查找活跃用户', + noActiveUsers: '本月没有活跃用户。', viewReceipt: '查看收据', viewInvoice: '查看发票', }, diff --git a/src/libs/PaymentHistoryUtils.ts b/src/libs/PaymentHistoryUtils.ts index 08cb14b0da1b..d91e31da27f4 100644 --- a/src/libs/PaymentHistoryUtils.ts +++ b/src/libs/PaymentHistoryUtils.ts @@ -203,6 +203,11 @@ function getActiveUserCount(purchase: Purchase): number | undefined { return emails.size > 0 ? emails.size : undefined; } +/** Returns the account IDs of the users billed for a purchase, deduped since the same person can be billed under more than one workspace. */ +function getActiveUserAccountIDs(purchase: Purchase | undefined): number[] { + return [...new Set(purchase?.activeUsers?.filter((accountID) => !!accountID))]; +} + function compareNewestFirst(left: PaymentHistoryRow, right: PaymentHistoryRow): number { if (left.created === right.created) { return right.purchaseID - left.purchaseID; @@ -371,6 +376,7 @@ function getReceiptFileName(source: string, purchaseID: number): string { export { formatDate, + getActiveUserAccountIDs, getBadgeAppearance, getBadgeText, getPaymentDetailsBadge, diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx new file mode 100644 index 000000000000..919e02c94eec --- /dev/null +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx @@ -0,0 +1,22 @@ +import BareUserListItem from '@components/SelectionList/ListItem/BareUserListItem'; +import type {ListItem, ListItemProps} from '@components/SelectionList/ListItem/types'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +/** A read-only active user row, styled so consecutive rows render as one card under the active user count header. */ +function PaymentActivityListItem(props: ListItemProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default PaymentActivityListItem; diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx index 8178de44ae0e..a1568bd08291 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx @@ -4,24 +4,38 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; +import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import UserPill from '@components/UserPill'; +import useDebouncedState from '@hooks/useDebouncedState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import {usePersonalDetail, usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {formatDate, getBadgeAppearance, getBadgeText, getPaymentDetailsBadge, getPaymentHistoryRow, getReceiptSource, isInvoiceBilling} from '@libs/PaymentHistoryUtils'; +import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; +import { + formatDate, + getActiveUserAccountIDs, + getBadgeAppearance, + getBadgeText, + getPaymentDetailsBadge, + getPaymentHistoryRow, + getReceiptSource, + isInvoiceBilling, +} from '@libs/PaymentHistoryUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import sortAlphabetically from '@libs/sortAlphabetically'; +import tokenizedSearch from '@libs/tokenizedSearch'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -35,15 +49,19 @@ import type SCREENS from '@src/SCREENS'; import React, {useEffect} from 'react'; import {View} from 'react-native'; +import PaymentActivityListItem from './PaymentActivityListItem'; + type PaymentDetailsPageProps = PlatformStackScreenProps; function PaymentDetailsPage({route}: PaymentDetailsPageProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'InvoiceGeneric']); - const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); + const {translate, dateFnsLocale, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); const [purchases] = useOnyx(ONYXKEYS.PURCHASES); + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const purchaseID = Number(route.params.purchaseID); const isBetaOn = isBetaEnabled(CONST.BETAS.PAYMENT_HISTORY); @@ -59,6 +77,8 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { const billedToAccountID = purchase?.billedToAccountID; const [billedToDetails] = usePersonalDetail(billedToAccountID); const row = getPaymentHistoryRow(purchases, purchaseID); + const activeUserAccountIDs = getActiveUserAccountIDs(purchase); + const [activeUserPersonalDetails] = usePersonalDetailsByIDs(activeUserAccountIDs); if (!isBetaOn || (purchases !== undefined && !row)) { return ; @@ -76,6 +96,119 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { const isInvoice = isInvoiceBilling(purchases, purchaseID); const billedToName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: billedToDetails, translate, formatPhoneNumber}); + + const activeUsers: ListItem[] = activeUserAccountIDs.map((accountID) => { + const details = activeUserPersonalDetails?.[accountID]; + return { + keyForList: String(accountID), + accountID, + login: details?.login ?? '', + text: temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}), + alternateText: formatPhoneNumber(details?.login ?? ''), + isInteractive: false, + }; + }); + const filteredActiveUsers = debouncedSearchTerm + ? tokenizedSearch(activeUsers, getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode), (option) => [option.text ?? '', option.login ?? '']) + : activeUsers; + const sortedActiveUsers = sortAlphabetically(filteredActiveUsers, 'text', localeCompare); + const hasActiveUsers = activeUsers.length > 0; + + const detailsContent = ( + <> + {!!row && ( + + + + {dateLabel} + + + {amountLabel} + + + + + {row.showsTax && {translate('subscription.paymentHistory.inclTax')}} + + + )} + {!!billedToName && ( + + {translate('subscription.paymentHistory.billedTo')} + + + )} + {!!receiptSource && ( + { + Navigation.navigate(ROUTES.BILLING_RECEIPT.getRoute(purchaseID)); + }} + sentryLabel={CONST.SENTRY_LABEL.SETTINGS_SUBSCRIPTION.VIEW_BILLING_RECEIPT} + accessibilityLabel={translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} + style={[styles.ph5, styles.pv4]} + > + + + + + {translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} + + + + + + )} + + {translate('subscription.paymentHistory.activity')} + + + ); + + // The count sits under the Activity header and tops the card the active user rows render into, so it's hidden when a search matches nobody. + let activityHeader = null; + if (!hasActiveUsers) { + activityHeader = ( + + {translate('subscription.paymentHistory.noActiveUsers')} + + ); + } else if (sortedActiveUsers.length > 0) { + activityHeader = ( + + + {translate('subscription.paymentHistory.activeUsers', {count: activeUsers.length})} + + + ); + } + return ( - - {!!row && ( - - - - {dateLabel} - - - {amountLabel} - - - - - {row.showsTax && {translate('subscription.paymentHistory.inclTax')}} - - - )} - {!!billedToName && ( - - {translate('subscription.paymentHistory.billedTo')} - - - )} - {!!receiptSource && ( - { - Navigation.navigate(ROUTES.BILLING_RECEIPT.getRoute(purchaseID)); - }} - sentryLabel={CONST.SENTRY_LABEL.SETTINGS_SUBSCRIPTION.VIEW_BILLING_RECEIPT} - accessibilityLabel={translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} - style={[styles.ph5, styles.pv4]} - > - - - - - {translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} - - - - - - )} - + {}} + customListHeaderContent={detailsContent} + customListHeader={activityHeader} + shouldHeaderBeInsideList + shouldShowTextInput={hasActiveUsers} + textInputOptions={{ + label: translate('subscription.paymentHistory.findActiveUser'), + value: searchTerm, + onChangeText: setSearchTerm, + headerMessage: debouncedSearchTerm && !sortedActiveUsers.length ? translate('common.noResultsFound') : '', + disableAutoFocus: true, + shouldBeInsideList: true, + }} + shouldShowListEmptyContent={false} + showScrollIndicator + /> ); diff --git a/tests/ui/PaymentDetailsPageTest.tsx b/tests/ui/PaymentDetailsPageTest.tsx new file mode 100644 index 000000000000..9bb90c39264a --- /dev/null +++ b/tests/ui/PaymentDetailsPageTest.tsx @@ -0,0 +1,136 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; + +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import PaymentDetailsPage from '@pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Purchase} from '@src/types/onyx/PurchaseList'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +TestHelper.setupGlobalFetchMock(); + +// The page fetches the purchase details on mount; the tests seed that data in Onyx instead. +jest.mock('@userActions/Subscription', () => ({ + __esModule: true, + openPaymentDetailsPage: jest.fn(), +})); + +const PURCHASE_ID = 1001; +const BOB = {accountID: 2, login: 'bob@example.com', displayName: 'Bob Clyde'}; +const TODD = {accountID: 3, login: 'todd@example.com', displayName: 'Todd Clyde'}; + +const Stack = createPlatformStackNavigator(); + +function renderPage() { + return render( + + + + + + + + + , + ); +} + +async function setPurchase(activeUsers: number[], billingType?: string) { + const purchase: Purchase = { + purchaseID: PURCHASE_ID, + created: '2026-02-01 00:00:00', + amount: 1000, + currency: CONST.CURRENCY.USD, + message: {billingType}, + activeUsers, + isLoaded: true, + }; + await Onyx.merge(ONYXKEYS.PURCHASES, {[PURCHASE_ID]: purchase}); + await waitForBatchedUpdatesWithAct(); +} + +describe('PaymentDetailsPage Activity list', () => { + beforeEach(async () => { + Onyx.init({keys: ONYXKEYS}); + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.PAYMENT_HISTORY]); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[BOB.accountID]: BOB, [TODD.accountID]: TODD}); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('lists each billed member once, with the deduped count under the Activity header', async () => { + // Given a month where Todd was billed under two workspaces + await setPurchase([TODD.accountID, BOB.accountID, TODD.accountID]); + + // When the details page renders + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the count matches the deduped members and each member is listed with their email + expect(await screen.findByText('Activity')).toBeOnTheScreen(); + expect(screen.getByText('2 active users')).toBeOnTheScreen(); + expect(screen.getByText(BOB.displayName)).toBeOnTheScreen(); + expect(screen.getByText(TODD.displayName)).toBeOnTheScreen(); + expect(screen.getByText(BOB.login)).toBeOnTheScreen(); + }); + + it('filters the list as the user searches', async () => { + // Given a month with two billed members + await setPurchase([TODD.accountID, BOB.accountID]); + renderPage(); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(BOB.displayName); + + // When the user searches for one of them + fireEvent.changeText(screen.getByTestId('selection-list-text-input'), 'bob'); + + // Then only the matching member stays in the list + await waitFor(() => expect(screen.queryByText(TODD.displayName)).toBeNull()); + expect(screen.getByText(BOB.displayName)).toBeOnTheScreen(); + + // And a search that matches nobody shows the no-results message + fireEvent.changeText(screen.getByTestId('selection-list-text-input'), 'zzzz'); + // The message is aria-hidden because the search input announces it, so the query has to include hidden elements + expect(await screen.findByText('No results found', {includeHiddenElements: true})).toBeOnTheScreen(); + expect(screen.queryByText(BOB.displayName)).toBeNull(); + }); + + it('shows an empty state for a failed month with no billed members', async () => { + // Given a failed month nobody was billed for + await setPurchase([], CONST.BILLING.TYPE_FAILED); + + // When the details page renders + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Activity section is still there, with an empty state instead of a blank section or a search field + expect(await screen.findByText('Activity')).toBeOnTheScreen(); + expect(screen.getByText('No active users this month.')).toBeOnTheScreen(); + expect(screen.queryByTestId('selection-list-text-input')).toBeNull(); + }); +}); diff --git a/tests/unit/PaymentHistoryUtilsTest.ts b/tests/unit/PaymentHistoryUtilsTest.ts index e47a90da3777..fd79998f4712 100644 --- a/tests/unit/PaymentHistoryUtilsTest.ts +++ b/tests/unit/PaymentHistoryUtilsTest.ts @@ -3,6 +3,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import { formatDate, + getActiveUserAccountIDs, getBadgeText, getPaymentDetailsBadge, getPaymentHistoryRow, @@ -430,3 +431,23 @@ describe('getReceiptFileName', () => { expect(getReceiptFileName('https://expensify.com/', 7)).toBe('receipt-7'); }); }); + +describe('getActiveUserAccountIDs', () => { + it('dedupes users billed under more than one workspace', () => { + // Given a purchase whose active users repeat, because one person was billed under two workspaces + const purchase = {...createPurchase(1, '2026-02-01'), activeUsers: [10, 20, 10, 30]}; + + // When the active users for the Activity list are read + // Then each person is listed once, so the count under the Activity header matches the list + expect(getActiveUserAccountIDs(purchase)).toEqual([10, 20, 30]); + }); + + it('returns no users when the details have not loaded or nobody was billed', () => { + // Given a missing purchase, a purchase without active users, and a purchase with an empty list + // When the active users are read + // Then the list is empty, so the page renders the empty state instead of a blank section + expect(getActiveUserAccountIDs(undefined)).toEqual([]); + expect(getActiveUserAccountIDs(createPurchase(1, '2026-02-01'))).toEqual([]); + expect(getActiveUserAccountIDs({...createPurchase(1, '2026-02-01'), activeUsers: []})).toEqual([]); + }); +}); From bbb8e2157f322228ac8087c21e0092c931393550 Mon Sep 17 00:00:00 2001 From: "Nguyen Van Duc (via MelvinBot)" Date: Mon, 19 Oct 2026 03:23:40 +0000 Subject: [PATCH 2/6] Adjust Activity section spacing and sizes on the payment details page - Activity label: 8px/20px padding and 12px bottom margin - Search input: 44px tall, 12px above the card - Active users count header: 36px tall - Activity rows: ph4 + pv1 wrapper Co-authored-by: Nguyen Van Duc --- src/components/SelectionList/components/TextInput.tsx | 2 +- src/components/SelectionList/types.ts | 3 +++ .../PaymentHistoryPage/PaymentActivityListItem.tsx | 2 +- .../Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx | 5 +++-- src/styles/utils/sizing.ts | 4 ++++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 591820f50008..c3f0af84a7fe 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -184,7 +184,7 @@ function TextInput({ shouldInterceptSwipe={shouldInterceptSwipe ?? false} // Size is based on device width (isSmallScreenWidth), not shouldUseNarrowLayout, so the input stays // the compact 34px size on web/desktop even inside the RHP/narrow pane, and only grows to 46px on mobile. - touchableInputWrapperStyle={isSmallScreenWidth ? styles.listSearchInputNarrowWrapper : styles.listSearchInputWideWrapper} + touchableInputWrapperStyle={[isSmallScreenWidth ? styles.listSearchInputNarrowWrapper : styles.listSearchInputWideWrapper, style?.inputWrapperStyle]} textInputContainerStyles={[styles.pb0, isSmallScreenWidth ? styles.ph3 : styles.ph2]} inputStyle={[styles.w100, styles.lineHeightUndefined, isSmallScreenWidth ? undefined : styles.fontSizeLabel]} /> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index a0e01b4bc049..354eb569cb8a 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -200,6 +200,9 @@ type TextInputOptions = { /** Styles for the text input container */ containerStyle?: StyleProp; + /** Styles for the text input wrapper, e.g. to override the default height */ + inputWrapperStyle?: StyleProp; + /** Styles for the header message container */ headerMessageStyle?: StyleProp; }; diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx index 919e02c94eec..e8351fca9596 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx @@ -13,7 +13,7 @@ function PaymentActivityListItem(props: ListItemProps ); diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx index a1568bd08291..33c1ec2713b9 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx @@ -180,7 +180,7 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { )} {translate('subscription.paymentHistory.activity')} @@ -198,7 +198,7 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { ); } else if (sortedActiveUsers.length > 0) { activityHeader = ( - + Date: Mon, 19 Oct 2026 08:25:48 +0000 Subject: [PATCH 3/6] Make the View receipt row title bold on the payment details page Co-authored-by: Nguyen Van Duc --- .../Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx index 33c1ec2713b9..fd73c09fed69 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx @@ -168,12 +168,7 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { - - {translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} - + {translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} From 652ac5643fda3675cb4f1df406bb2b3a3bda1de4 Mon Sep 17 00:00:00 2001 From: "Nguyen Van Duc (via MelvinBot)" Date: Tue, 20 Oct 2026 01:38:49 +0000 Subject: [PATCH 4/6] Use the default search input size and hide the Activity section when there are no active users Co-authored-by: Nguyen Van Duc --- .../SelectionList/components/TextInput.tsx | 2 +- src/components/SelectionList/types.ts | 3 -- src/languages/de.ts | 1 - src/languages/el.ts | 1 - src/languages/en.ts | 1 - src/languages/es.ts | 1 - src/languages/fr.ts | 1 - src/languages/it.ts | 1 - src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - .../PaymentHistoryPage/PaymentDetailsPage.tsx | 29 +++++++------------ tests/ui/PaymentDetailsPageTest.tsx | 9 +++--- 15 files changed, 17 insertions(+), 37 deletions(-) diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index c3f0af84a7fe..591820f50008 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -184,7 +184,7 @@ function TextInput({ shouldInterceptSwipe={shouldInterceptSwipe ?? false} // Size is based on device width (isSmallScreenWidth), not shouldUseNarrowLayout, so the input stays // the compact 34px size on web/desktop even inside the RHP/narrow pane, and only grows to 46px on mobile. - touchableInputWrapperStyle={[isSmallScreenWidth ? styles.listSearchInputNarrowWrapper : styles.listSearchInputWideWrapper, style?.inputWrapperStyle]} + touchableInputWrapperStyle={isSmallScreenWidth ? styles.listSearchInputNarrowWrapper : styles.listSearchInputWideWrapper} textInputContainerStyles={[styles.pb0, isSmallScreenWidth ? styles.ph3 : styles.ph2]} inputStyle={[styles.w100, styles.lineHeightUndefined, isSmallScreenWidth ? undefined : styles.fontSizeLabel]} /> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 354eb569cb8a..a0e01b4bc049 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -200,9 +200,6 @@ type TextInputOptions = { /** Styles for the text input container */ containerStyle?: StyleProp; - /** Styles for the text input wrapper, e.g. to override the default height */ - inputWrapperStyle?: StyleProp; - /** Styles for the header message container */ headerMessageStyle?: StyleProp; }; diff --git a/src/languages/de.ts b/src/languages/de.ts index b244de1539e4..6f43f591d7a3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -11547,7 +11547,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc billedTo: 'Abgerechnet an', activity: 'Aktivität', findActiveUser: 'Aktiven Benutzer suchen', - noActiveUsers: 'In diesem Monat gab es keine aktiven Benutzer.', viewReceipt: 'Beleg anzeigen', viewInvoice: 'Rechnung anzeigen', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index d2ae78ee2655..dedfc2c551b3 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -11763,7 +11763,6 @@ ${reportName}`, billedTo: 'Χρέωση σε', activity: 'Δραστηριότητα', findActiveUser: 'Εύρεση ενεργού χρήστη', - noActiveUsers: 'Δεν υπήρξαν ενεργοί χρήστες αυτόν τον μήνα.', viewReceipt: 'Προβολή απόδειξης', viewInvoice: 'Προβολή τιμολογίου', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 7e01ddda37fc..82ab8c2f17e3 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -11807,7 +11807,6 @@ const translations = { billedTo: 'Billed to', activity: 'Activity', findActiveUser: 'Find an active user', - noActiveUsers: 'No active users this month.', viewReceipt: 'View receipt', viewInvoice: 'View invoice', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 55970db162d0..20a35e287671 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -11734,7 +11734,6 @@ ${reportName}`, billedTo: 'Facturado a', activity: 'Actividad', findActiveUser: 'Buscar un usuario activo', - noActiveUsers: 'No hubo usuarios activos este mes.', viewReceipt: 'Ver recibo', viewInvoice: 'Ver factura', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 119fcf79571d..52d59d0c1acd 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -11573,7 +11573,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e billedTo: 'Facturé à', activity: 'Activité', findActiveUser: 'Rechercher un utilisateur actif', - noActiveUsers: 'Aucun utilisateur actif ce mois-ci.', viewReceipt: 'Voir le reçu', viewInvoice: 'Voir la facture', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 4fb576ebdf83..e2b0a3266a4b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -11496,7 +11496,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, billedTo: 'Fatturato a', activity: 'Attività', findActiveUser: 'Trova un utente attivo', - noActiveUsers: 'Nessun utente attivo questo mese.', viewReceipt: 'Visualizza ricevuta', viewInvoice: 'Visualizza fattura', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f99d3bff1f9a..5a883cd998dc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -11330,7 +11330,6 @@ ${reportName}`, billedTo: '請求先', activity: 'アクティビティ', findActiveUser: 'アクティブユーザーを検索', - noActiveUsers: '今月のアクティブユーザーはいません。', viewReceipt: '領収書を表示', viewInvoice: '請求書を表示', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d1e0a46558d3..a0f9b84929ac 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -11457,7 +11457,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, billedTo: 'Gefactureerd aan', activity: 'Activiteit', findActiveUser: 'Zoek een actieve gebruiker', - noActiveUsers: 'Geen actieve gebruikers deze maand.', viewReceipt: 'Bon bekijken', viewInvoice: 'Factuur bekijken', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 09498a935e8c..361786451152 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -11478,7 +11478,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, billedTo: 'Obciążono', activity: 'Aktywność', findActiveUser: 'Znajdź aktywnego użytkownika', - noActiveUsers: 'W tym miesiącu nie było aktywnych użytkowników.', viewReceipt: 'Wyświetl paragon', viewInvoice: 'Wyświetl fakturę', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 157c703c6ae6..ca0329470ae3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -11450,7 +11450,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, billedTo: 'Cobrado de', activity: 'Atividade', findActiveUser: 'Encontrar um usuário ativo', - noActiveUsers: 'Nenhum usuário ativo neste mês.', viewReceipt: 'Ver recibo', viewInvoice: 'Ver fatura', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a1c7f02768a3..837b0ca3c8eb 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -11037,7 +11037,6 @@ ${reportName}`, billedTo: '账单对象', activity: '活动', findActiveUser: '查找活跃用户', - noActiveUsers: '本月没有活跃用户。', viewReceipt: '查看收据', viewInvoice: '查看发票', }, diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx index fd73c09fed69..19f7434b8402 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx @@ -174,25 +174,20 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { )} - - {translate('subscription.paymentHistory.activity')} - + {hasActiveUsers && ( + + {translate('subscription.paymentHistory.activity')} + + )} ); // The count sits under the Activity header and tops the card the active user rows render into, so it's hidden when a search matches nobody. - let activityHeader = null; - if (!hasActiveUsers) { - activityHeader = ( - - {translate('subscription.paymentHistory.noActiveUsers')} - - ); - } else if (sortedActiveUsers.length > 0) { - activityHeader = ( + const activityHeader = + sortedActiveUsers.length > 0 ? ( - ); - } + ) : null; return ( { expect(screen.queryByText(BOB.displayName)).toBeNull(); }); - it('shows an empty state for a failed month with no billed members', async () => { + it('hides the Activity section for a month with no billed members', async () => { // Given a failed month nobody was billed for await setPurchase([], CONST.BILLING.TYPE_FAILED); @@ -128,9 +128,10 @@ describe('PaymentDetailsPage Activity list', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - // Then the Activity section is still there, with an empty state instead of a blank section or a search field - expect(await screen.findByText('Activity')).toBeOnTheScreen(); - expect(screen.getByText('No active users this month.')).toBeOnTheScreen(); + // Then the details still show, but the Activity label, the search field, and the count are all hidden + expect(await screen.findByText('$10.00')).toBeOnTheScreen(); + expect(screen.queryByText('Activity')).toBeNull(); expect(screen.queryByTestId('selection-list-text-input')).toBeNull(); + expect(screen.queryByText(/active user/)).toBeNull(); }); }); From f02c6a9e1fe60ea6782b0f00cec63eadcf2dcd46 Mon Sep 17 00:00:00 2001 From: "Nguyen Van Duc (via MelvinBot)" Date: Wed, 21 Oct 2026 09:51:45 +0000 Subject: [PATCH 5/6] Switch the payment Activity list to Table and only show its search bar for 12+ active users Co-authored-by: Nguyen Van Duc --- .../PaymentActivityTableRow.tsx | 54 +++++++++++ .../Tables/PaymentActivityTable/index.tsx | 95 +++++++++++++++++++ .../PaymentActivityListItem.tsx | 22 ----- .../PaymentHistoryPage/PaymentDetailsPage.tsx | 63 +++--------- src/styles/utils/sizing.ts | 4 - tests/ui/PaymentDetailsPageTest.tsx | 31 ++++-- 6 files changed, 182 insertions(+), 87 deletions(-) create mode 100644 src/components/Tables/PaymentActivityTable/PaymentActivityTableRow.tsx create mode 100644 src/components/Tables/PaymentActivityTable/index.tsx delete mode 100644 src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx diff --git a/src/components/Tables/PaymentActivityTable/PaymentActivityTableRow.tsx b/src/components/Tables/PaymentActivityTable/PaymentActivityTableRow.tsx new file mode 100644 index 000000000000..4d2748bc52eb --- /dev/null +++ b/src/components/Tables/PaymentActivityTable/PaymentActivityTableRow.tsx @@ -0,0 +1,54 @@ +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; +import Table from '@components/Table'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {PaymentActivityRowData} from '.'; + +type PaymentActivityTableRowProps = { + /** The active user shown in the row */ + item: PaymentActivityRowData; + + /** The index of the row relative to all other rows */ + rowIndex: number; +}; + +/** A read-only row showing an active user's avatar, name, and email. */ +export default function PaymentActivityTableRow({item, rowIndex}: PaymentActivityTableRowProps) { + const styles = useThemeStyles(); + + const accessibilityLabel = [item.name, item.email].filter(Boolean).join(', '); + + return ( + + + + + + + + + + ); +} diff --git a/src/components/Tables/PaymentActivityTable/index.tsx b/src/components/Tables/PaymentActivityTable/index.tsx new file mode 100644 index 000000000000..ac52770a329e --- /dev/null +++ b/src/components/Tables/PaymentActivityTable/index.tsx @@ -0,0 +1,95 @@ +import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; +import Table, {composeTableListHeader} from '@components/Table'; + +import useLocalize from '@hooks/useLocalize'; + +import tokenizedSearch from '@libs/tokenizedSearch'; + +import CONST from '@src/CONST'; + +import type {ListRenderItemInfo} from '@shopify/flash-list'; + +import React from 'react'; + +import PaymentActivityTableRow from './PaymentActivityTableRow'; + +type PaymentActivityTableColumnKey = 'member'; + +type PaymentActivityRowData = TableData & { + accountID: number; + + /** The active user's login (email/phone) */ + login: string; + + /** The active user's display name */ + name: string; + + /** The active user's formatted email/phone shown as the subtitle */ + email: string; +}; + +type PaymentActivityTableProps = { + /** The active users billed for the month */ + activeUsers: PaymentActivityRowData[]; + + /** Page-level content rendered above the table header inside the scrollable list */ + headerComponent?: React.ReactElement; +}; + +function PaymentActivityTable({activeUsers, headerComponent}: PaymentActivityTableProps) { + const {translate, localeCompare} = useLocalize(); + + // The count always reflects every active user, not just the ones matching the search. + const activeUsersLabel = translate('subscription.paymentHistory.activeUsers', {count: activeUsers.length}); + + const columns: Array> = [ + { + key: 'member', + label: activeUsersLabel, + sortable: false, + }, + ]; + + const compareItems: CompareItemsCallback = (item1, item2) => localeCompare(item1.name.toLowerCase(), item2.name.toLowerCase()); + + const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => { + const results = tokenizedSearch([item], searchValue, (option) => [option.name, option.email, option.login]); + return results.length > 0; + }; + + const renderItem = ({item, index}: ListRenderItemInfo) => ( + + ); + + // Short lists are easy to scan, so the search bar only shows once there are enough active users to search through. + const shouldShowSearchBar = activeUsers.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const tableHeaderComponent = composeTableListHeader( + headerComponent, + shouldShowSearchBar ? : undefined, + ); + + return ( + item.keyForList} + > + {tableHeaderComponent} + + + +
+ ); +} + +export default PaymentActivityTable; + +export type {PaymentActivityRowData}; diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx deleted file mode 100644 index e8351fca9596..000000000000 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentActivityListItem.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import BareUserListItem from '@components/SelectionList/ListItem/BareUserListItem'; -import type {ListItem, ListItemProps} from '@components/SelectionList/ListItem/types'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; - -/** A read-only active user row, styled so consecutive rows render as one card under the active user count header. */ -function PaymentActivityListItem(props: ListItemProps) { - const styles = useThemeStyles(); - - return ( - - ); -} - -export default PaymentActivityListItem; diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx index 19f7434b8402..debdcb40234c 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx @@ -4,12 +4,11 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import type {ListItem} from '@components/SelectionList/types'; +import PaymentActivityTable from '@components/Tables/PaymentActivityTable'; +import type {PaymentActivityRowData} from '@components/Tables/PaymentActivityTable'; import Text from '@components/Text'; import UserPill from '@components/UserPill'; -import useDebouncedState from '@hooks/useDebouncedState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -22,7 +21,6 @@ import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import { formatDate, getActiveUserAccountIDs, @@ -34,8 +32,6 @@ import { isInvoiceBilling, } from '@libs/PaymentHistoryUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import sortAlphabetically from '@libs/sortAlphabetically'; -import tokenizedSearch from '@libs/tokenizedSearch'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -49,19 +45,15 @@ import type SCREENS from '@src/SCREENS'; import React, {useEffect} from 'react'; import {View} from 'react-native'; -import PaymentActivityListItem from './PaymentActivityListItem'; - type PaymentDetailsPageProps = PlatformStackScreenProps; function PaymentDetailsPage({route}: PaymentDetailsPageProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'InvoiceGeneric']); - const {translate, dateFnsLocale, formatPhoneNumber, localeCompare} = useLocalize(); + const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); const [purchases] = useOnyx(ONYXKEYS.PURCHASES); - const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const purchaseID = Number(route.params.purchaseID); const isBetaOn = isBetaEnabled(CONST.BETAS.PAYMENT_HISTORY); @@ -97,21 +89,16 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { const billedToName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: billedToDetails, translate, formatPhoneNumber}); - const activeUsers: ListItem[] = activeUserAccountIDs.map((accountID) => { + const activeUsers: PaymentActivityRowData[] = activeUserAccountIDs.map((accountID) => { const details = activeUserPersonalDetails?.[accountID]; return { keyForList: String(accountID), accountID, login: details?.login ?? '', - text: temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}), - alternateText: formatPhoneNumber(details?.login ?? ''), - isInteractive: false, + name: temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}), + email: formatPhoneNumber(details?.login ?? ''), }; }); - const filteredActiveUsers = debouncedSearchTerm - ? tokenizedSearch(activeUsers, getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode), (option) => [option.text ?? '', option.login ?? '']) - : activeUsers; - const sortedActiveUsers = sortAlphabetically(filteredActiveUsers, 'text', localeCompare); const hasActiveUsers = activeUsers.length > 0; const detailsContent = ( @@ -185,19 +172,6 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { ); - // The count sits under the Activity header and tops the card the active user rows render into, so it's hidden when a search matches nobody. - const activityHeader = - sortedActiveUsers.length > 0 ? ( - - - {translate('subscription.paymentHistory.activeUsers', {count: activeUsers.length})} - - - ) : null; - return ( - {}} - customListHeaderContent={detailsContent} - customListHeader={activityHeader} - shouldHeaderBeInsideList - shouldShowTextInput={hasActiveUsers} - textInputOptions={{ - label: translate('subscription.paymentHistory.findActiveUser'), - value: searchTerm, - onChangeText: setSearchTerm, - headerMessage: debouncedSearchTerm && !sortedActiveUsers.length ? translate('common.noResultsFound') : '', - disableAutoFocus: true, - shouldBeInsideList: true, - }} - shouldShowListEmptyContent={false} - showScrollIndicator - /> + + + ); diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 9a37dc89ad07..2c2cedf56c26 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -21,10 +21,6 @@ export default { height: 32, }, - h9: { - height: 36, - }, - h10: { height: 40, }, diff --git a/tests/ui/PaymentDetailsPageTest.tsx b/tests/ui/PaymentDetailsPageTest.tsx index 3e5c19a95f0e..8445dafc4c8c 100644 --- a/tests/ui/PaymentDetailsPageTest.tsx +++ b/tests/ui/PaymentDetailsPageTest.tsx @@ -36,6 +36,14 @@ const PURCHASE_ID = 1001; const BOB = {accountID: 2, login: 'bob@example.com', displayName: 'Bob Clyde'}; const TODD = {accountID: 3, login: 'todd@example.com', displayName: 'Todd Clyde'}; +// Enough extra members to reach the search bar threshold together with Bob and Todd +const OTHER_MEMBERS = Array.from({length: CONST.STANDARD_LIST_ITEM_LIMIT - 2}, (_, index) => ({ + accountID: 100 + index, + login: `member${index}@example.com`, + displayName: `Member ${index}`, +})); +const SEARCH_PLACEHOLDER = 'Find an active user'; + const Stack = createPlatformStackNavigator(); function renderPage() { @@ -75,7 +83,7 @@ describe('PaymentDetailsPage Activity list', () => { Onyx.init({keys: ONYXKEYS}); await Onyx.clear(); await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.PAYMENT_HISTORY]); - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[BOB.accountID]: BOB, [TODD.accountID]: TODD}); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, Object.fromEntries([BOB, TODD, ...OTHER_MEMBERS].map((member) => [member.accountID, member]))); await waitForBatchedUpdatesWithAct(); }); @@ -97,26 +105,29 @@ describe('PaymentDetailsPage Activity list', () => { expect(screen.getByText(BOB.displayName)).toBeOnTheScreen(); expect(screen.getByText(TODD.displayName)).toBeOnTheScreen(); expect(screen.getByText(BOB.login)).toBeOnTheScreen(); + + // And the search bar is hidden because two members are quick to scan + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull(); }); - it('filters the list as the user searches', async () => { - // Given a month with two billed members - await setPurchase([TODD.accountID, BOB.accountID]); + it('filters the list as the user searches once there are enough members to search', async () => { + // Given a month with enough billed members to show the search bar + await setPurchase([TODD.accountID, BOB.accountID, ...OTHER_MEMBERS.map((member) => member.accountID)]); renderPage(); await waitForBatchedUpdatesWithAct(); await screen.findByText(BOB.displayName); // When the user searches for one of them - fireEvent.changeText(screen.getByTestId('selection-list-text-input'), 'bob'); + fireEvent.changeText(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), 'bob'); - // Then only the matching member stays in the list + // Then only the matching member stays in the list, and the count still shows every active user await waitFor(() => expect(screen.queryByText(TODD.displayName)).toBeNull()); expect(screen.getByText(BOB.displayName)).toBeOnTheScreen(); + expect(screen.getByText(`${CONST.STANDARD_LIST_ITEM_LIMIT} active users`)).toBeOnTheScreen(); // And a search that matches nobody shows the no-results message - fireEvent.changeText(screen.getByTestId('selection-list-text-input'), 'zzzz'); - // The message is aria-hidden because the search input announces it, so the query has to include hidden elements - expect(await screen.findByText('No results found', {includeHiddenElements: true})).toBeOnTheScreen(); + fireEvent.changeText(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), 'zzzz'); + expect(await screen.findByText('No results found')).toBeOnTheScreen(); expect(screen.queryByText(BOB.displayName)).toBeNull(); }); @@ -131,7 +142,7 @@ describe('PaymentDetailsPage Activity list', () => { // Then the details still show, but the Activity label, the search field, and the count are all hidden expect(await screen.findByText('$10.00')).toBeOnTheScreen(); expect(screen.queryByText('Activity')).toBeNull(); - expect(screen.queryByTestId('selection-list-text-input')).toBeNull(); + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull(); expect(screen.queryByText(/active user/)).toBeNull(); }); }); From 5e13eb7042a70fa1f69ccccad6b4d796a70dfa6c Mon Sep 17 00:00:00 2001 From: "Nguyen Van Duc (via MelvinBot)" Date: Wed, 21 Oct 2026 15:56:52 +0000 Subject: [PATCH 6/6] Update the empty active users test comment Co-authored-by: Nguyen Van Duc --- tests/unit/PaymentHistoryUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/PaymentHistoryUtilsTest.ts b/tests/unit/PaymentHistoryUtilsTest.ts index fd79998f4712..b5343f8eec0e 100644 --- a/tests/unit/PaymentHistoryUtilsTest.ts +++ b/tests/unit/PaymentHistoryUtilsTest.ts @@ -445,7 +445,7 @@ describe('getActiveUserAccountIDs', () => { it('returns no users when the details have not loaded or nobody was billed', () => { // Given a missing purchase, a purchase without active users, and a purchase with an empty list // When the active users are read - // Then the list is empty, so the page renders the empty state instead of a blank section + // Then the list is empty, so the page hides the Activity section expect(getActiveUserAccountIDs(undefined)).toEqual([]); expect(getActiveUserAccountIDs(createPurchase(1, '2026-02-01'))).toEqual([]); expect(getActiveUserAccountIDs({...createPurchase(1, '2026-02-01'), activeUsers: []})).toEqual([]);