From ab9893a6116e84b932bd197b94a767e571daa46c Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Wed, 18 Dec 2024 13:50:37 -0600 Subject: [PATCH 01/45] Initial commit --- .../billing-history-data-view/README.md | 10 + .../billing-history-filters.jsx | 228 ++++++ .../billing-history-list.tsx | 276 +++++++ .../billing-history-data-view/controller.js | 17 + .../filter-transactions.ts | 91 +++ .../billing-history-data-view/main.tsx | 83 ++ .../billing-history-data-view/receipt.tsx | 728 ++++++++++++++++++ .../billing-history-data-view/style.scss | 53 ++ .../test/filter-transactions.ts | 403 ++++++++++ .../test/paginate-transactions.ts | 287 +++++++ .../billing-history-data-view/test/tax.tsx | 272 +++++++ .../billing-history-data-view/test/utils.ts | 354 +++++++++ .../billing-history-data-view/utils.tsx | 344 +++++++++ .../vat-vendor-details.tsx | 35 + client/me/purchases/billing-history/main.tsx | 15 +- config/development.json | 1 + config/horizon.json | 1 + config/production.json | 1 + config/stage.json | 1 + config/wpcalypso.json | 1 + 20 files changed, 3199 insertions(+), 2 deletions(-) create mode 100644 client/me/purchases/billing-history-data-view/README.md create mode 100644 client/me/purchases/billing-history-data-view/billing-history-filters.jsx create mode 100644 client/me/purchases/billing-history-data-view/billing-history-list.tsx create mode 100644 client/me/purchases/billing-history-data-view/controller.js create mode 100644 client/me/purchases/billing-history-data-view/filter-transactions.ts create mode 100644 client/me/purchases/billing-history-data-view/main.tsx create mode 100644 client/me/purchases/billing-history-data-view/receipt.tsx create mode 100644 client/me/purchases/billing-history-data-view/style.scss create mode 100644 client/me/purchases/billing-history-data-view/test/filter-transactions.ts create mode 100644 client/me/purchases/billing-history-data-view/test/paginate-transactions.ts create mode 100644 client/me/purchases/billing-history-data-view/test/tax.tsx create mode 100644 client/me/purchases/billing-history-data-view/test/utils.ts create mode 100644 client/me/purchases/billing-history-data-view/utils.tsx create mode 100644 client/me/purchases/billing-history-data-view/vat-vendor-details.tsx diff --git a/client/me/purchases/billing-history-data-view/README.md b/client/me/purchases/billing-history-data-view/README.md new file mode 100644 index 0000000000000..1afa0cdf7468c --- /dev/null +++ b/client/me/purchases/billing-history-data-view/README.md @@ -0,0 +1,10 @@ +# Billing History + +This is the Billing History React component that renders the /me/purchases/billing/ route. + +Supported routes: + +``` +/me/purchases/billing +/me/purchases/billing/:receiptId +``` diff --git a/client/me/purchases/billing-history-data-view/billing-history-filters.jsx b/client/me/purchases/billing-history-data-view/billing-history-filters.jsx new file mode 100644 index 0000000000000..3560c7da88eb1 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/billing-history-filters.jsx @@ -0,0 +1,228 @@ +import { SelectDropdown } from '@automattic/components'; +import closest from 'component-closest'; +import { localize } from 'i18n-calypso'; +import { find, isEqual } from 'lodash'; +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { withLocalizedMoment } from 'calypso/components/localized-moment'; +import { recordGoogleEvent } from 'calypso/state/analytics/actions'; +import { setApp, setDate } from 'calypso/state/billing-transactions/ui/actions'; +import getBillingTransactionAppFilterValues from 'calypso/state/selectors/get-billing-transaction-app-filter-values'; +import getBillingTransactionDateFilterValues from 'calypso/state/selectors/get-billing-transaction-date-filter-values'; +import getBillingTransactionFilters from 'calypso/state/selectors/get-billing-transaction-filters'; + +class BillingHistoryFilters extends Component { + state = { + activePopover: '', + }; + + preventEnterKeySubmission = ( event ) => { + event.preventDefault(); + }; + + componentDidMount() { + document.body.addEventListener( 'click', this.closePopoverIfClickedOutside ); + } + + componentWillUnmount() { + document.body.removeEventListener( 'click', this.closePopoverIfClickedOutside ); + } + + recordClickEvent = ( action ) => { + this.props.recordGoogleEvent( 'Me', 'Clicked on ' + action ); + }; + + getDatePopoverItemClickHandler( analyticsEvent, date ) { + return () => { + const { transactionType } = this.props; + this.recordClickEvent( 'Date Popover Item: ' + analyticsEvent ); + this.props.setDate( transactionType, date.month, date.operator ); + this.setState( { activePopover: '' } ); + }; + } + + getAppPopoverItemClickHandler( analyticsEvent, app ) { + return () => { + this.recordClickEvent( 'App Popover Item: ' + analyticsEvent ); + this.props.setApp( this.props.transactionType, app ); + this.setState( { activePopover: '' } ); + }; + } + + handleDatePopoverLinkClick = () => { + this.recordClickEvent( 'Toggle Date Popover in Billing History' ); + this.togglePopover( 'date' ); + }; + + handleAppsPopoverLinkClick = () => { + this.recordClickEvent( 'Toggle Apps Popover in Billing History' ); + this.togglePopover( 'apps' ); + }; + + closePopoverIfClickedOutside = ( event ) => { + if ( closest( event.target, 'thead' ) ) { + return; + } + + this.setState( { activePopover: '' } ); + }; + + render() { + return ( + + + + { this.renderDatePopover() } + { this.renderAppsPopover() } + + + + ); + } + + getFilterTitle( filter ) { + if ( ! filter ) { + return this.props.translate( 'Date' ); + } + + if ( filter.older ) { + return this.props.translate( 'Older' ); + } + + return this.props.moment( filter.dateString ).format( 'MMM YYYY' ); + } + + renderDatePopover() { + const { dateFilters, filter, translate } = this.props; + const selectedFilter = find( dateFilters, { value: filter.date } ); + const selectedText = this.getFilterTitle( selectedFilter ); + + return ( + + { translate( 'Recent Transactions' ) } + { this.renderDatePicker( + 'Newest', + translate( 'Newest' ), + { + month: null, + operator: null, + }, + null + ) } + + { translate( 'By Month' ) } + { dateFilters.map( ( dateFilter, index ) => { + let analyticsEvent = 'Current Month'; + + if ( 1 === index ) { + analyticsEvent = '1 Month Before'; + } else if ( 1 < index ) { + analyticsEvent = index + ' Months Before'; + } + + return this.renderDatePicker( + index, + this.getFilterTitle( dateFilter ), + dateFilter.value, + dateFilter.count, + analyticsEvent + ); + } ) } + + ); + } + + togglePopover( name ) { + let activePopover; + if ( this.state.activePopover === name ) { + activePopover = ''; + } else { + activePopover = name; + } + + this.setState( { activePopover: activePopover } ); + } + + renderDatePicker( titleKey, titleTranslated, value, count, analyticsEvent ) { + const currentDate = this.props.filter.date; + const isSelected = isEqual( currentDate, value ); + analyticsEvent = 'undefined' === typeof analyticsEvent ? titleKey : analyticsEvent; + + return ( + + { titleTranslated } + + ); + } + + renderAppsPopover() { + const { appFilters, filter, translate } = this.props; + const selectedFilter = find( appFilters, { value: filter.app } ); + const selectedText = selectedFilter ? selectedFilter.title : translate( 'All apps' ); + + return ( + + { translate( 'App name' ) } + { this.renderAppPicker( translate( 'All apps' ), 'all' ) } + { appFilters.map( function ( { title, value, count } ) { + return this.renderAppPicker( title, value, count, 'Specific App' ); + }, this ) } + + ); + } + + renderAppPicker( title, app, count, analyticsEvent ) { + const selected = app === this.props.filter.app; + + return ( + + { title } + + ); + } +} + +BillingHistoryFilters.propTypes = { + //connected props + appFilters: PropTypes.array.isRequired, + dateFilters: PropTypes.array.isRequired, + filter: PropTypes.object.isRequired, + //own props + transactionType: PropTypes.string.isRequired, + siteId: PropTypes.number, +}; + +export default connect( + ( state, { transactionType, siteId } ) => ( { + appFilters: getBillingTransactionAppFilterValues( state, transactionType, siteId ), + dateFilters: getBillingTransactionDateFilterValues( state, transactionType, siteId ), + filter: getBillingTransactionFilters( state, transactionType ), + } ), + { + recordGoogleEvent, + setApp, + setDate, + } +)( localize( withLocalizedMoment( BillingHistoryFilters ) ) ); diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx new file mode 100644 index 0000000000000..be89c937d1b64 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -0,0 +1,276 @@ +import { DataViews } from '@wordpress/dataviews'; +import { localize, LocalizeProps } from 'i18n-calypso'; +import moment from 'moment'; +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { withLocalizedMoment } from 'calypso/components/localized-moment'; +import { capitalPDangit } from 'calypso/lib/formatting'; +import { recordGoogleEvent } from 'calypso/state/analytics/actions'; +import { sendBillingReceiptEmail as sendBillingReceiptEmailAction } from 'calypso/state/billing-transactions/actions'; +import { + BillingTransaction, + BillingTransactionItem, +} from 'calypso/state/billing-transactions/types'; +import { setPage } from 'calypso/state/billing-transactions/ui/actions'; +import getBillingTransactionFilters from 'calypso/state/selectors/get-billing-transaction-filters'; +import getPastBillingTransactions from 'calypso/state/selectors/get-past-billing-transactions'; +import isSendingBillingReceiptEmail from 'calypso/state/selectors/is-sending-billing-receipt-email'; +import { IAppState } from 'calypso/state/types'; +import { filterTransactions, paginateTransactions } from './filter-transactions'; +import { + getTransactionTermLabel, + groupDomainProducts, + TransactionAmount, + renderTransactionQuantitySummary, +} from './utils'; +import type { MouseEvent } from 'react'; + +import '@wordpress/dataviews/build-style/style.css'; +import './style.scss'; + +export interface BillingHistoryListProps { + header?: boolean; + siteId?: string | number | null; + getReceiptUrlFor: ( receiptId: string ) => string; +} + +export interface BillingHistoryListConnectedProps { + app?: string; + date: { newest: boolean }; + page: number; + pageSize: number; + query: string; + total: number; + transactions: BillingTransaction[]; + sendingBillingReceiptEmail: ( receiptId: string ) => boolean; + moment: typeof moment; + sendBillingReceiptEmail: ( receiptId: string ) => void; + setPage: ( transactionType: string, page: number ) => void; +} + +class BillingHistoryListDataView extends Component< + BillingHistoryListProps & BillingHistoryListConnectedProps & LocalizeProps +> { + static displayName = 'BillingHistoryList'; + + static defaultProps = { + header: false, + }; + + onPageClick = ( page: number ) => { + this.props.setPage( 'past', page ); + }; + + render() { + const transactions = this.props.transactions || []; + + return ( +
+
+ { + const newPage = typeof newView.page === 'number' ? newView.page : 1; + if ( newView.page !== this.props.page ) { + this.props.setPage( 'past', newPage ); + } + } } + defaultLayouts={ { table: {} } } + actions={ this.getActions() } + isLoading={ false } + /> +
+
+ ); + } + + serviceName = ( transaction: BillingTransaction ) => { + const [ transactionItem, ...moreTransactionItems ] = groupDomainProducts( + transaction.items, + this.props.translate + ); + + if ( moreTransactionItems.length > 0 ) { + return { this.props.translate( 'Multiple items' ) }; + } + + if ( transactionItem.product === transactionItem.variation ) { + return transactionItem.product; + } + + return this.serviceNameDescription( transactionItem ); + }; + + getFields = () => { + return [ + { + id: 'date', + label: 'Date', + render: ( { item }: { item: BillingTransaction } ) => { + const date = this.props.moment( item.date ).format( 'll' ); + return ; + }, + }, + { + id: 'service', + label: 'Service', + render: ( { item }: { item: BillingTransaction } ) => { + return
{ this.serviceName( item ) }
; + }, + }, + { + id: 'amount', + label: 'Amount', + render: ( { item }: { item: BillingTransaction } ) => { + return ; + }, + }, + ]; + }; + + getActions = () => { + const { getReceiptUrlFor, sendBillingReceiptEmail } = this.props; + return [ + { + id: 'view-receipt', + label: 'View receipt', + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + window.location.href = getReceiptUrlFor( item.id ); + }, + }, + { + id: 'email-receipt', + label: 'Email receipt', + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + this.recordClickEvent( 'Email Receipt in Billing History' ); + sendBillingReceiptEmail( item.id ); + }, + }, + ]; + }; + + getView = () => { + const { page, pageSize } = this.props; + return { + type: 'table', + search: '', + filters: [], + page: page, + perPage: pageSize, + sort: { + field: 'date', + direction: 'desc', + }, + titleField: 'title', + fields: [ 'date', 'service', 'amount', 'actions' ], + layout: {}, + }; + }; + + serviceNameDescription = ( transaction: BillingTransactionItem ) => { + const plan = capitalPDangit( transaction.variation ); + const termLabel = getTransactionTermLabel( transaction, this.props.translate ); + return ( +
+ { plan } + { transaction.domain && { transaction.domain } } + { termLabel && { termLabel } } + { transaction.licensed_quantity && ( + { renderTransactionQuantitySummary( transaction, this.props.translate ) } + ) } +
+ ); + }; + + recordClickEvent = ( eventAction: string ) => { + recordGoogleEvent( 'Me', eventAction ); + }; + + handleReceiptLinkClick = () => { + return this.recordClickEvent( 'View Receipt in Billing History' ); + }; + + getEmailReceiptLinkClickHandler = ( receiptId: string ) => { + const { sendBillingReceiptEmail } = this.props; + + return ( event: MouseEvent< HTMLButtonElement > ) => { + event.preventDefault(); + this.recordClickEvent( 'Email Receipt in Billing History' ); + sendBillingReceiptEmail( receiptId ); + }; + }; + + renderEmailAction = ( receiptId: string ) => { + const { translate, sendingBillingReceiptEmail } = this.props; + + if ( sendingBillingReceiptEmail( receiptId ) ) { + return translate( 'Emailing receipt…' ); + } + + return ( + + ); + }; + + renderActions = ( transaction: BillingTransaction ) => { + const { translate, getReceiptUrlFor } = this.props; + + return ( +
+ + { translate( 'View receipt' ) } + +
+ ); + }; +} + +function getIsSendingReceiptEmail( state: IAppState ) { + return function isSendingBillingReceiptEmailForReceiptId( receiptId: number ) { + return isSendingBillingReceiptEmail( state, receiptId ); + }; +} + +export default connect( + ( state: IAppState, { siteId }: BillingHistoryListProps ) => { + const transactions = getPastBillingTransactions( state ); + const pageSize = 10; + const filteredTransactions = transactions && filterTransactions( transactions, {}, siteId ); + + const uiState = getBillingTransactionFilters( state, 'past' ); + const currentPage = uiState?.page ? uiState.page : 1; + + const paginatedTransactions = + filteredTransactions && paginateTransactions( filteredTransactions, currentPage, pageSize ); + + return { + page: currentPage, + pageSize, + total: filteredTransactions?.length ?? 0, + transactions: paginatedTransactions, + sendingBillingReceiptEmail: getIsSendingReceiptEmail( state ), + }; + }, + { + setPage, + sendBillingReceiptEmail: sendBillingReceiptEmailAction, + } +)( localize( withLocalizedMoment( BillingHistoryListDataView ) ) ); diff --git a/client/me/purchases/billing-history-data-view/controller.js b/client/me/purchases/billing-history-data-view/controller.js new file mode 100644 index 0000000000000..d7b9d14946fc9 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/controller.js @@ -0,0 +1,17 @@ +import { createElement } from 'react'; +import BillingHistoryComponent from 'calypso/me/purchases/billing-history/main'; +import Receipt from './receipt'; + +export function billingHistory( context, next ) { + context.primary = createElement( BillingHistoryComponent ); + next(); +} + +export function transaction( context, next ) { + const receiptId = parseInt( context.params.receiptId, 10 ); + + if ( receiptId ) { + context.primary = createElement( Receipt, { transactionId: receiptId } ); + } + next(); +} diff --git a/client/me/purchases/billing-history-data-view/filter-transactions.ts b/client/me/purchases/billing-history-data-view/filter-transactions.ts new file mode 100644 index 0000000000000..4ca8bebf8e61a --- /dev/null +++ b/client/me/purchases/billing-history-data-view/filter-transactions.ts @@ -0,0 +1,91 @@ +import { isValueTruthy } from '@automattic/wpcom-checkout'; +import { getLocaleSlug } from 'i18n-calypso'; +import moment from 'moment'; +import { + BillingTransaction, + BillingTransactionUiState, +} from 'calypso/state/billing-transactions/types'; + +/** + * Utility function for formatting date for text search + */ +function formatDate( date: string ): string { + const localeSlug = getLocaleSlug(); + return moment( date ) + .locale( localeSlug ?? '' ) + .format( 'll' ); +} + +/** + * Utility function extracting searchable strings from a single transaction + */ +function getSearchableStrings( transaction: BillingTransaction ): string[] { + const rootStrings: string[] = Object.values( transaction ).filter( + ( value ) => typeof value === 'string' + ); + const dateString: string | null = transaction.date ? formatDate( transaction.date ) : null; + const itemStrings: string[] = transaction.items.flatMap( ( item ) => Object.values( item ) ); + + return [ ...rootStrings, dateString, ...itemStrings ].filter( isValueTruthy ); +} + +/** + * Utility function to search the transactions by the provided searchQuery + */ +function searchTransactions( + transactions: BillingTransaction[], + searchQuery: string +): BillingTransaction[] { + const needle = searchQuery.toLowerCase(); + + return transactions.filter( ( transaction: BillingTransaction ) => + getSearchableStrings( transaction ).some( ( val ) => { + const haystack = val.toString().toLowerCase(); + return haystack.includes( needle ); + } ) + ); +} + +export function filterTransactions( + transactions: BillingTransaction[] | null | undefined, + filter: BillingTransactionUiState, + siteId: number | string | null | undefined +): BillingTransaction[] { + const { app, date, query } = filter; + let results = query ? searchTransactions( transactions ?? [], query ) : transactions ?? []; + + if ( date && date.month && date.operator ) { + results = results.filter( ( transaction ) => { + const transactionDate = moment( transaction.date ); + + if ( 'equal' === date.operator ) { + return transactionDate.isSame( date.month, 'month' ); + } else if ( 'before' === date.operator ) { + return transactionDate.isBefore( date.month, 'month' ); + } + } ); + } + + if ( app && app !== 'all' ) { + results = results.filter( ( transaction ) => transaction.service === app ); + } + + if ( siteId ) { + results = results.filter( ( transaction ) => { + return transaction.items.some( ( receiptItem ) => { + return String( receiptItem.site_id ) === String( siteId ); + } ); + } ); + } + + return results; +} + +export function paginateTransactions( + transactions: BillingTransaction[], + page: number | null | undefined, + pageSize: number +): BillingTransaction[] { + const pageIndex = ( page ?? 1 ) - 1; + return transactions.slice( pageIndex * pageSize, pageIndex * pageSize + pageSize ); +} diff --git a/client/me/purchases/billing-history-data-view/main.tsx b/client/me/purchases/billing-history-data-view/main.tsx new file mode 100644 index 0000000000000..c07a37713ce16 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/main.tsx @@ -0,0 +1,83 @@ +import config from '@automattic/calypso-config'; +import { CompactCard, Card } from '@automattic/components'; +import { useTranslate } from 'i18n-calypso'; +import DocumentHead from 'calypso/components/data/document-head'; +import QueryBillingTransactions from 'calypso/components/data/query-billing-transactions'; +import InlineSupportLink from 'calypso/components/inline-support-link'; +import Main from 'calypso/components/main'; +import NavigationHeader from 'calypso/components/navigation-header'; +import { useGeoLocationQuery } from 'calypso/data/geo/use-geolocation-query'; +import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; +import BillingHistoryList from 'calypso/me/purchases/billing-history/billing-history-list'; +import { vatDetails as vatDetailsPath, billingHistoryReceipt } from 'calypso/me/purchases/paths'; +import PurchasesNavigation from 'calypso/me/purchases/purchases-navigation'; +import titles from 'calypso/me/purchases/titles'; +import useVatDetails from 'calypso/me/purchases/vat-info/use-vat-details'; +import { useTaxName } from 'calypso/my-sites/checkout/src/hooks/use-country-list'; + +import './style.scss'; +import '@wordpress/dataviews/build-style/style.css'; + +export function BillingHistoryContent( { + siteId = null, + getReceiptUrlFor = billingHistoryReceipt, +}: { + siteId: number | null; + getReceiptUrlFor: ( receiptId: string | number ) => string; +} ) { + return ( + + + + ); +} + +function BillingHistory() { + const translate = useTranslate(); + const { vatDetails } = useVatDetails(); + const { data: geoData } = useGeoLocationQuery(); + const taxName = useTaxName( vatDetails.country ?? geoData?.country_short ?? 'GB' ); + + const genericTaxName = + /* translators: This is a generic name for taxes to use when we do not know the user's country. */ + translate( 'tax (VAT/GST/CT)' ); + const fallbackTaxName = genericTaxName; + /* translators: %s is the name of taxes in the country (eg: "VAT" or "GST"). */ + const editVatText = translate( 'Edit %s details', { + textOnly: true, + args: [ taxName ?? fallbackTaxName ], + } ); + /* translators: %s is the name of taxes in the country (eg: "VAT" or "GST"). */ + const addVatText = translate( 'Add %s details', { + textOnly: true, + args: [ taxName ?? fallbackTaxName ], + } ); + const vatText = vatDetails.id ? editVatText : addVatText; + + return ( +
+ + + , + }, + } + ) } + /> + + + + + { config.isEnabled( 'me/vat-details' ) && ( + { vatText } + ) } +
+ ); +} +export default BillingHistory; diff --git a/client/me/purchases/billing-history-data-view/receipt.tsx b/client/me/purchases/billing-history-data-view/receipt.tsx new file mode 100644 index 0000000000000..1a38a16c7dd74 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/receipt.tsx @@ -0,0 +1,728 @@ +import config from '@automattic/calypso-config'; +import page from '@automattic/calypso-router'; +import { Button, Card, FormLabel } from '@automattic/components'; +import { formatCurrency } from '@automattic/format-currency'; +import { IntroductoryOfferTerms } from '@automattic/shopping-cart'; +import { + LineItemCostOverrideForDisplay, + doesIntroductoryOfferHaveDifferentTermLengthThanProduct, + getIntroductoryOfferIntervalDisplay, + isUserVisibleCostOverride, +} from '@automattic/wpcom-checkout'; +import clsx from 'clsx'; +import { localize, useTranslate } from 'i18n-calypso'; +import { Component, useState, useCallback } from 'react'; +import { connect } from 'react-redux'; +import DocumentHead from 'calypso/components/data/document-head'; +import QueryBillingTransaction from 'calypso/components/data/query-billing-transaction'; +import HeaderCake from 'calypso/components/header-cake'; +import { withLocalizedMoment, useLocalizedMoment } from 'calypso/components/localized-moment'; +import Main from 'calypso/components/main'; +import NavigationHeader from 'calypso/components/navigation-header'; +import TextareaAutosize from 'calypso/components/textarea-autosize'; +import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; +import { PARTNER_PAYPAL_EXPRESS, PARTNER_PAYPAL_PPCP } from 'calypso/lib/checkout/payment-methods'; +import { billingHistory, vatDetails as vatDetailsPath } from 'calypso/me/purchases/paths'; +import titles from 'calypso/me/purchases/titles'; +import useVatDetails from 'calypso/me/purchases/vat-info/use-vat-details'; +import { useTaxName } from 'calypso/my-sites/checkout/src/hooks/use-country-list'; +import { useDispatch } from 'calypso/state'; +import { recordGoogleEvent } from 'calypso/state/analytics/actions'; +import { sendBillingReceiptEmail } from 'calypso/state/billing-transactions/actions'; +import { + clearBillingTransactionError, + requestBillingTransaction, +} from 'calypso/state/billing-transactions/individual-transactions/actions'; +import getPastBillingTransaction from 'calypso/state/selectors/get-past-billing-transaction'; +import isPastBillingTransactionError from 'calypso/state/selectors/is-past-billing-transaction-error'; +import { + getTransactionTermLabel, + groupDomainProducts, + renderTransactionQuantitySummary, + renderDomainTransactionVolumeSummary, + transactionIncludesTax, +} from './utils'; +import { VatVendorDetails } from './vat-vendor-details'; +import type { + BillingTransaction, + BillingTransactionItem, + ReceiptCostOverride, +} from 'calypso/state/billing-transactions/types'; +import type { IAppState } from 'calypso/state/types'; +import type { LocalizeProps } from 'i18n-calypso'; +import type { FormEvent } from 'react'; + +import './style.scss'; + +interface BillingReceiptProps { + transactionId: number; + recordGoogleEvent: ( key: string, message: string ) => void; + clearBillingTransactionError: ( transactionId: number ) => void; +} + +interface BillingReceiptConnectedProps { + transactionFetchError?: string; + transaction: BillingTransaction | undefined; + translate: LocalizeProps[ 'translate' ]; +} + +class BillingReceipt extends Component< BillingReceiptProps & BillingReceiptConnectedProps > { + componentDidMount() { + this.redirectIfInvalidTransaction(); + } + + componentDidUpdate() { + this.redirectIfInvalidTransaction(); + } + + recordClickEvent = ( action: string ) => { + this.props.recordGoogleEvent( 'Me', 'Clicked on ' + action ); + }; + + handlePrintLinkClick = () => { + this.recordClickEvent( 'Print Receipt Button in Billing History Receipt' ); + window.print(); + }; + + redirectIfInvalidTransaction() { + const { transactionFetchError, transactionId } = this.props; + + if ( ! transactionFetchError ) { + return; + } + + this.props.clearBillingTransactionError( transactionId ); + page.redirect( billingHistory ); + } + + render() { + const { transaction, transactionId, translate } = this.props; + + return ( +
+ + + + + + + + + + { transaction ? ( + + ) : ( + + ) } +
+ ); + } +} + +export function ReceiptBody( { + transaction, + handlePrintLinkClick, +}: { + transaction: BillingTransaction; + handlePrintLinkClick: () => void; +} ) { + const translate = useTranslate(); + const moment = useLocalizedMoment(); + const title = translate( 'Visit %(url)s', { args: { url: transaction.url }, textOnly: true } ); + const serviceLink = ; + + return ( +
+ +
+ { +

+ { translate( '{{link}}%(service)s{{/link}} {{small}}by %(organization)s{{/small}}', { + components: { + link: serviceLink, + small: , + }, + args: { + service: transaction.service, + organization: transaction.org, + }, + comment: + 'This string is "Service by Organization". ' + + 'The {{link}} and {{small}} add html styling and attributes. ' + + 'Screenshot: https://cloudup.com/isX-WEFYlOs', + } ) } + { transaction.address } +

+ + { moment( transaction.date ).format( 'll' ) } + +
+
    +
  • + { translate( 'Receipt ID' ) } + { transaction.id } +
  • + + + { transaction.cc_num !== 'XXXX' ? ( + + ) : ( + + ) } + { config.isEnabled( 'me/vat-details' ) && } +
+ + +
+ +
+
+
+ ); +} + +function ReceiptTransactionId( { transaction }: { transaction: BillingTransaction } ) { + const translate = useTranslate(); + if ( ! transaction.pay_ref ) { + return null; + } + + return ( +
  • + { translate( 'Transaction ID' ) } + { transaction.pay_ref } +
  • + ); +} + +function ReceiptPaymentMethod( { transaction }: { transaction: BillingTransaction } ) { + const translate = useTranslate(); + let text; + + if ( + transaction.pay_part === PARTNER_PAYPAL_EXPRESS || + transaction.pay_part === PARTNER_PAYPAL_PPCP + ) { + text = translate( 'PayPal' ); + } else if ( 'XXXX' !== transaction.cc_num ) { + text = translate( '%(cardType)s ending in %(cardNum)s', { + args: { + cardType: + transaction.cc_display_brand?.replace( '_', ' ' ).toUpperCase() ?? + transaction.cc_type.toUpperCase(), + cardNum: transaction.cc_num, + }, + } ); + } else { + return null; + } + + return ( +
  • + { translate( 'Payment Method' ) } + { text } +
  • + ); +} + +function UserVatDetails( { transaction }: { transaction: BillingTransaction } ) { + const translate = useTranslate(); + const { vatDetails, isLoading, fetchError } = useVatDetails(); + const reduxDispatch = useDispatch(); + + const getEmailReceiptLinkClickHandler = ( receiptId: string ) => { + return ( event: FormEvent< HTMLFormElement > ) => { + event.preventDefault(); + reduxDispatch( recordGoogleEvent( 'Me', 'Clicked on Receipt Email Button' ) ); + reduxDispatch( sendBillingReceiptEmail( receiptId ) ); + }; + }; + + if ( isLoading || fetchError || ! vatDetails.id ) { + return null; + } + + return ( +
  • + { translate( 'VAT Details' ) } + + { translate( + '{{noPrint}}You can edit your VAT details {{vatDetailsLink}}on this page{{/vatDetailsLink}}. {{/noPrint}}This is not an official VAT receipt. For an official VAT receipt, {{emailReceiptLink}}email yourself a copy{{/emailReceiptLink}}.', + { + components: { + noPrint: , + vatDetailsLink: , + emailReceiptLink: ( +
  • + ); +} + +function VatDetails( { transaction }: { transaction: BillingTransaction } ) { + return ( + <> + + + + ); +} + +function getDiscountReasonForIntroductoryOffer( + product: BillingTransactionItem, + terms: IntroductoryOfferTerms, + translate: ReturnType< typeof useTranslate >, + allowFreeText: boolean, + isPriceIncrease: boolean +): string { + return getIntroductoryOfferIntervalDisplay( { + translate, + intervalUnit: terms.interval_unit, + intervalCount: terms.interval_count, + isFreeTrial: product.amount_integer === 0 && allowFreeText, + isPriceIncrease, + context: 'checkout', + remainingRenewalsUsingOffer: terms.transition_after_renewal_count, + } ); +} + +function makeIntroductoryOfferCostOverrideUnique( + costOverride: ReceiptCostOverride, + product: BillingTransactionItem, + translate: ReturnType< typeof useTranslate > +): ReceiptCostOverride { + // Replace introductory offer cost override text with wording specific to + // that offer. + if ( 'introductory-offer' === costOverride.override_code && product.introductory_offer_terms ) { + return { + ...costOverride, + human_readable_reason: getDiscountReasonForIntroductoryOffer( + product, + product.introductory_offer_terms, + translate, + true, + costOverride.new_price_integer > costOverride.old_price_integer + ), + }; + } + return costOverride; +} + +function filterCostOverridesForReceiptItem( + item: BillingTransactionItem, + translate: ReturnType< typeof useTranslate > +): LineItemCostOverrideForDisplay[] { + return item.cost_overrides + .filter( ( costOverride ) => isUserVisibleCostOverride( costOverride ) ) + .map( ( costOverride ) => + makeIntroductoryOfferCostOverrideUnique( costOverride, item, translate ) + ) + .map( ( costOverride ) => { + // Introductory offer discounts with term lengths that differ from + // the term length of the product (eg: a 3 month discount for an + // annual plan) need to be displayed differently because the + // discount is only temporary and the user will still be charged + // the remainder before the next renewal. + if ( + doesIntroductoryOfferHaveDifferentTermLengthThanProduct( + item.cost_overrides, + item.introductory_offer_terms, + item.months_per_renewal_interval + ) + ) { + return { + humanReadableReason: costOverride.human_readable_reason, + overrideCode: costOverride.override_code, + }; + } + return { + humanReadableReason: costOverride.human_readable_reason, + overrideCode: costOverride.override_code, + discountAmount: costOverride.old_price_integer - costOverride.new_price_integer, + }; + } ); +} + +function areReceiptItemDiscountsAccurate( receiptDate: string ): boolean { + const date = new Date( receiptDate ); + const receiptDateUnix = date.getTime() / 1000; + // D129863-code and D133350-code fixed volume discounts. Before that, cost + // override tags may be incomplete. The latter was merged on Jan 2, 2024, + // 17:54 UTC. + const receiptTagsAccurateAsOf = 1704218040; + return receiptDateUnix > receiptTagsAccurateAsOf; +} + +function ReceiptItemDiscountIntroductoryOfferDate( { item }: { item: BillingTransactionItem } ) { + const translate = useTranslate(); + if ( ! item.introductory_offer_terms?.enabled ) { + return null; + } + if ( + ! doesIntroductoryOfferHaveDifferentTermLengthThanProduct( + item.cost_overrides, + item.introductory_offer_terms, + item.months_per_renewal_interval + ) + ) { + return null; + } + + return ( +
    +
    + { translate( 'Amount paid in transaction: %(price)s', { + args: { + price: formatCurrency( item.amount_integer, item.currency, { + isSmallestUnit: true, + stripZeros: true, + } ), + }, + } ) } +
    +
    + ); +} + +function ReceiptItemDiscounts( { + item, + receiptDate, +}: { + item: BillingTransactionItem; + receiptDate: string; +} ) { + const shouldShowDiscount = areReceiptItemDiscountsAccurate( receiptDate ); + const translate = useTranslate(); + return ( +
      + { filterCostOverridesForReceiptItem( item, translate ).map( ( costOverride ) => { + const formattedDiscountAmount = + shouldShowDiscount && costOverride.discountAmount + ? formatCurrency( -costOverride.discountAmount, item.currency, { + isSmallestUnit: true, + stripZeros: true, + } ) + : ''; + if ( + doesIntroductoryOfferHaveDifferentTermLengthThanProduct( + item.cost_overrides, + item.introductory_offer_terms, + item.months_per_renewal_interval + ) + ) { + return ( +
    • +
      { costOverride.humanReadableReason }
      + +
    • + ); + } + return ( +
    • + { costOverride.humanReadableReason } + { formattedDiscountAmount } +
    • + ); + } ) } +
    + ); +} + +/** + * Calculate the original cost for a receipt item by looking at any cost + * overrides. + * + * Returns the number in the currency's smallest unit. + */ +function getReceiptItemOriginalCost( item: BillingTransactionItem ): number { + if ( item.type === 'refund' ) { + return item.subtotal_integer; + } + const originalCostOverrides = item.cost_overrides.filter( + ( override ) => override.does_override_original_cost + ); + if ( originalCostOverrides.length > 0 ) { + const lastOriginalCostOverride = originalCostOverrides.pop(); + if ( lastOriginalCostOverride ) { + return lastOriginalCostOverride.new_price_integer; + } + } + if ( item.cost_overrides.length > 0 ) { + const firstOverride = item.cost_overrides[ 0 ]; + if ( firstOverride ) { + return firstOverride.old_price_integer; + } + } + return item.subtotal_integer; +} + +function ReceiptItemTaxes( { transaction }: { transaction: BillingTransaction } ) { + const translate = useTranslate(); + const taxName = useTaxName( transaction.tax_country_code ); + + if ( ! transactionIncludesTax( transaction ) ) { + return null; + } + + return ( +
    + { taxName ?? translate( 'Tax' ) } + + { formatCurrency( transaction.tax_integer, transaction.currency, { + isSmallestUnit: true, + stripZeros: true, + } ) } + +
    + ); +} + +function ReceiptLineItem( { + item, + transaction, +}: { + item: BillingTransactionItem; + transaction: BillingTransaction; +} ) { + const translate = useTranslate(); + const termLabel = getTransactionTermLabel( item, translate ); + const shouldShowDiscount = areReceiptItemDiscountsAccurate( transaction.date ); + const formattedAmount = formatCurrency( + shouldShowDiscount ? getReceiptItemOriginalCost( item ) : item.subtotal_integer, + item.currency, + { + isSmallestUnit: true, + stripZeros: true, + } + ); + return ( + <> + + + { item.variation } + ({ item.type_localized }) + { termLabel && { termLabel } } + { item.domain && { item.domain } } + { item.licensed_quantity && ( + { renderTransactionQuantitySummary( item, translate ) } + ) } + { item.volume && { renderDomainTransactionVolumeSummary( item, translate ) } } + + + { doesIntroductoryOfferHaveDifferentTermLengthThanProduct( + item.cost_overrides, + item.introductory_offer_terms, + item.months_per_renewal_interval + ) ? ( + { formattedAmount } + ) : ( + formattedAmount + ) } + { transaction.credit && ( + { translate( 'Refund' ) } + ) } + + + + + + + + + ); +} + +function ReceiptLineItems( { transaction }: { transaction: BillingTransaction } ) { + const translate = useTranslate(); + const groupedTransactionItems = groupDomainProducts( transaction.items, translate ); + + return ( +
    +

    { translate( 'Order summary' ) }

    + + + + + + + + + { groupedTransactionItems.map( ( item ) => ( + + ) ) } + + + + + + + + + + +
    { translate( 'Description' ) }{ translate( 'Amount' ) }
    + +
    + + { translate( 'Total paid:', { comment: 'Total amount paid for product' } ) } + + + { formatCurrency( transaction.amount_integer, transaction.currency, { + isSmallestUnit: true, + stripZeros: true, + } ) } +
    +
    + ); +} + +function ReceiptDetails( { transaction }: { transaction: BillingTransaction } ) { + if ( ! transaction.cc_name && ! transaction.cc_email ) { + return null; + } + + return ( +
  • + + +
  • + ); +} + +function EmptyReceiptDetails() { + // When the content of the text area is empty, hide the "Billing Details" label for printing. + const [ hideDetailsLabelOnPrint, setHideDetailsLabelOnPrint ] = useState( true ); + const onChange = useCallback( + ( e: React.ChangeEvent< HTMLTextAreaElement > ) => { + const value = e.target.value.trim(); + if ( hideDetailsLabelOnPrint && value.length > 0 ) { + setHideDetailsLabelOnPrint( false ); + } else if ( ! hideDetailsLabelOnPrint && value.length === 0 ) { + setHideDetailsLabelOnPrint( true ); + } + }, + [ hideDetailsLabelOnPrint, setHideDetailsLabelOnPrint ] + ); + + return ( +
  • + + +
  • + ); +} + +export function ReceiptPlaceholder() { + return ( + +
    +
    +
    +
    + +
    +
    +
    +
    + + ); +} + +function ReceiptLabels( { hideDetailsLabelOnPrint }: { hideDetailsLabelOnPrint?: boolean } ) { + const translate = useTranslate(); + + let labelContent = translate( + 'Use this field to add your billing information (eg. VAT number, business address) before printing.' + ); + if ( config.isEnabled( 'me/vat-details' ) ) { + labelContent = translate( + 'Use this field to add your billing information (eg. business address) before printing.' + ); + } + return ( +
    + + { translate( 'Billing Details' ) } + +
    + { labelContent } +
    +
    + ); +} + +export function ReceiptTitle( { backHref }: { backHref: string } ) { + const translate = useTranslate(); + return { translate( 'Receipt' ) }; +} + +export default connect( + ( state: IAppState, { transactionId }: { transactionId: number } ) => { + const transaction = getPastBillingTransaction( state, transactionId ); + return { + transaction: transaction && 'service' in transaction ? transaction : undefined, + transactionFetchError: isPastBillingTransactionError( state, transactionId ), + }; + }, + { + clearBillingTransactionError, + recordGoogleEvent, + requestBillingTransaction, + } +)( localize( withLocalizedMoment( BillingReceipt ) ) ); diff --git a/client/me/purchases/billing-history-data-view/style.scss b/client/me/purchases/billing-history-data-view/style.scss new file mode 100644 index 0000000000000..1fd0ec9f9ead7 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/style.scss @@ -0,0 +1,53 @@ +.billing-history { + .dataviews-wrapper { + width: 100%; + + .dataviews-view-table__row { + background: var(--studio-white); + opacity: 1; + + th { + border-bottom: 1px solid var(--color-neutral-5); + font-size: 0.8125rem; + font-weight: 400; + vertical-align: middle; + padding: 12px; + text-align: left; + } + + td { + border-bottom: 1px solid var(--color-neutral-5); + vertical-align: middle; + padding: 12px; + text-align: left; + } + } + + .dataviews-view-table__cell-content-wrapper { + font-size: 0.8125rem; + } + + small { + display: block; + } + } + + &__email-button { + background: none; + border: none; + color: var(--color-link); + cursor: pointer; + font-weight: normal; + padding: 0; + text-decoration: underline; + + &:hover { + color: var(--color-link-dark); + } + } + + &__transaction-links { + display: flex; + gap: 1em; + } +} diff --git a/client/me/purchases/billing-history-data-view/test/filter-transactions.ts b/client/me/purchases/billing-history-data-view/test/filter-transactions.ts new file mode 100644 index 0000000000000..f2911858acdd4 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/test/filter-transactions.ts @@ -0,0 +1,403 @@ +import { cloneDeep } from 'lodash'; +import { + BillingTransaction, + BillingTransactionItem, +} from 'calypso/state/billing-transactions/types'; +import getBillingTransactionFilters from 'calypso/state/selectors/get-billing-transaction-filters'; +import { filterTransactions } from '../filter-transactions'; + +const mockTransaction: BillingTransaction = { + currency: 'USD', + address: '', + amount: '', + amount_integer: 0, + tax_country_code: '', + cc_email: '', + cc_name: '', + cc_num: '', + cc_type: '', + credit: '', + date: '', + desc: '', + icon: '', + id: '', + items: [], + org: '', + pay_part: '', + pay_ref: '', + service: '', + subtotal: '', + subtotal_integer: 0, + support: '', + tax: '', + tax_integer: 0, + url: '', +}; + +const mockItem: BillingTransactionItem = { + id: '', + type: '', + type_localized: '', + domain: '', + site_id: '', + subtotal: '', + subtotal_integer: 0, + tax_integer: 0, + amount_integer: 0, + tax: '', + amount: '', + raw_subtotal: 0, + raw_tax: 0, + raw_amount: 0, + currency: '', + licensed_quantity: null, + new_quantity: null, + product: '', + product_slug: '', + variation: '', + variation_slug: '', + months_per_renewal_interval: 0, + wpcom_product_slug: '', +}; + +const past: BillingTransaction[] = [ + { + ...mockTransaction, + date: '2018-05-01T12:00:00', + service: 'WordPress.com', + cc_name: 'name1 surname1', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$3.50', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2018-04-11T13:11:27', + service: 'WordPress.com', + cc_name: 'name2', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$5.75', + type: 'new purchase', + variation: 'Variation2', + }, + { + ...mockItem, + amount: '$8.00', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2018-03-11T21:00:00', + service: 'Store Services', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$3.50', + type: 'new purchase', + variation: 'Variation2', + }, + { + ...mockItem, + amount: '$5.00', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2018-03-15T10:39:27', + service: 'Store Services', + cc_name: 'name2', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$4.86', + type: 'new purchase', + variation: 'Variation1', + }, + { + ...mockItem, + amount: '$1.23', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2018-03-13T16:10:45', + service: 'WordPress.com', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$3.50', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2018-01-10T14:24:38', + service: 'WordPress.com', + cc_name: 'name2', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$4.20', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2017-12-10T10:30:38', + service: 'Store Services', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$3.75', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2017-12-01T07:20:00', + service: 'Store Services', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$9.50', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2017-11-24T05:13:00', + service: 'WordPress.com', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$8.40', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2017-01-01T00:00:00', + service: 'Store Services', + cc_name: 'name2', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$2.40', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, +]; + +const state = { + billingTransactions: { + items: { + past, + }, + ui: { past: undefined, upcoming: undefined }, + }, +}; + +describe( 'filterTransactions()', () => { + describe( 'date filter', () => { + test( 'returns all transactions when filtering by newest', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + date: { month: null, operator: null }, + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( result ).toEqual( state.billingTransactions.items.past ); + } ); + + test( 'returns transactions filtered by month', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + date: { month: '2018-03', operator: 'equal' }, + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( result ).toHaveLength( 3 ); + expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); + expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 2 ); + expect( new Date( result[ 2 ].date ).getMonth() ).toBe( 2 ); + } ); + + test( 'returns transactions before the month set in the filter', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + date: { month: '2017-12', operator: 'before' }, + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 10 ); + expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 0 ); + } ); + } ); + + describe( 'app filter', () => { + test( 'returns all transactions when the filter is empty', () => { + const filter = getBillingTransactionFilters( state as any, 'past' ); + const result = filterTransactions( state.billingTransactions.items.past, filter, null ); + expect( result ).toEqual( state.billingTransactions.items.past ); + } ); + + test( 'returns transactions filtered by app name', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + app: 'Store Services', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + result.forEach( ( transaction ) => { + expect( transaction.service ).toEqual( 'Store Services' ); + } ); + } ); + } ); + + describe( 'search query', () => { + test( 'query matches a field in the root transaction object', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + query: 'mastercard', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( state.billingTransactions.items.past, filter, null ); + result.forEach( ( transaction ) => { + expect( transaction.cc_type ).toEqual( 'mastercard' ); + } ); + } ); + + test( 'query matches date of a transaction', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + query: 'may 1', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + result.forEach( ( transaction ) => { + expect( transaction.date ).toBe( '2018-05-01T12:00:00' ); + } ); + } ); + + test( 'query matches a field in the transaction items array', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + query: '$3.50', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); + expect( result[ 1 ].items ).toMatchObject( [ { amount: '$3.50' }, { amount: '$5.00' } ] ); + expect( result[ 2 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); + } ); + } ); + + describe( 'filter combinations', () => { + test( 'date and app filters', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + date: { month: '2018-03', operator: 'equal' }, + app: 'Store Services', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); + expect( result[ 0 ].service ).toEqual( 'Store Services' ); + expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 2 ); + expect( result[ 1 ].service ).toEqual( 'Store Services' ); + } ); + + test( 'app and query filters', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + app: 'Store Services', + query: '$3.50', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' }, { amount: '$5.00' } ] ); + expect( result[ 0 ].service ).toEqual( 'Store Services' ); + } ); + + test( 'date and query filters', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + date: { month: '2018-05', operator: 'equal' }, + query: '$3.50', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); + expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 4 ); + } ); + + test( 'app, date and query filters', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + date: { month: '2018-03', operator: 'equal' }, + query: 'visa', + app: 'WordPress.com', + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( result[ 0 ].cc_type ).toEqual( 'visa' ); + expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); + expect( result[ 0 ].service ).toEqual( 'WordPress.com' ); + } ); + } ); + + describe( 'no results', () => { + test( 'should return all expected meta fields including an empty transactions array', () => { + const testState = cloneDeep( state ); + testState.billingTransactions.ui.past = { + date: { month: '2019-01', operator: 'equal' }, + }; + const filter = getBillingTransactionFilters( testState as any, 'past' ); + const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); + expect( result ).toEqual( [] ); + } ); + } ); +} ); diff --git a/client/me/purchases/billing-history-data-view/test/paginate-transactions.ts b/client/me/purchases/billing-history-data-view/test/paginate-transactions.ts new file mode 100644 index 0000000000000..de5205c611666 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/test/paginate-transactions.ts @@ -0,0 +1,287 @@ +import { cloneDeep } from 'lodash'; +import { + BillingTransaction, + BillingTransactionItem, +} from 'calypso/state/billing-transactions/types'; +import { paginateTransactions } from '../filter-transactions'; + +const mockTransaction: BillingTransaction = { + currency: 'USD', + address: '', + amount: '', + amount_integer: 0, + tax_country_code: '', + cc_email: '', + cc_name: '', + cc_num: '', + cc_type: '', + credit: '', + date: '', + desc: '', + icon: '', + id: '', + items: [], + org: '', + pay_part: '', + pay_ref: '', + service: '', + subtotal: '', + subtotal_integer: 0, + support: '', + tax: '', + tax_integer: 0, + url: '', +}; + +const mockItem: BillingTransactionItem = { + id: '', + type: '', + type_localized: '', + domain: '', + site_id: '', + subtotal: '', + subtotal_integer: 0, + tax_integer: 0, + amount_integer: 0, + tax: '', + amount: '', + raw_subtotal: 0, + raw_tax: 0, + raw_amount: 0, + currency: '', + licensed_quantity: null, + new_quantity: null, + product: '', + product_slug: '', + variation: '', + variation_slug: '', + months_per_renewal_interval: 0, + wpcom_product_slug: '', +}; + +const past: BillingTransaction[] = [ + { + ...mockTransaction, + date: '2018-05-01T12:00:00', + service: 'WordPress.com', + cc_name: 'name1 surname1', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$3.50', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2018-04-11T13:11:27', + service: 'WordPress.com', + cc_name: 'name2', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$5.75', + type: 'new purchase', + variation: 'Variation2', + }, + { + ...mockItem, + amount: '$8.00', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2018-03-11T21:00:00', + service: 'Store Services', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$3.50', + type: 'new purchase', + variation: 'Variation2', + }, + { + ...mockItem, + amount: '$5.00', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2018-03-15T10:39:27', + service: 'Store Services', + cc_name: 'name2', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$4.86', + type: 'new purchase', + variation: 'Variation1', + }, + { + ...mockItem, + amount: '$1.23', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2018-03-13T16:10:45', + service: 'WordPress.com', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$3.50', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2018-01-10T14:24:38', + service: 'WordPress.com', + cc_name: 'name2', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$4.20', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2017-12-10T10:30:38', + service: 'Store Services', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$3.75', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2017-12-01T07:20:00', + service: 'Store Services', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$9.50', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, + { + ...mockTransaction, + date: '2017-11-24T05:13:00', + service: 'WordPress.com', + cc_name: 'name1 surname1', + cc_type: 'visa', + items: [ + { + ...mockItem, + amount: '$8.40', + type: 'new purchase', + variation: 'Variation2', + }, + ], + }, + { + ...mockTransaction, + date: '2017-01-01T00:00:00', + service: 'Store Services', + cc_name: 'name2', + cc_type: 'mastercard', + items: [ + { + ...mockItem, + amount: '$2.40', + type: 'new purchase', + variation: 'Variation1', + }, + ], + }, +]; + +const state = { + billingTransactions: { + items: { + past, + }, + }, +}; + +describe( 'paginateTransactions()', () => { + test( 'returns all transactions when there are fewer than the page limit', () => { + const testState = cloneDeep( state ); + const pageSize = testState.billingTransactions.items.past.length + 1; + const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); + expect( result ).toEqual( state.billingTransactions.items.past ); + } ); + + test( 'returns all transactions when there are the same number as the page limit', () => { + const testState = cloneDeep( state ); + const pageSize = testState.billingTransactions.items.past.length; + const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); + expect( result ).toEqual( state.billingTransactions.items.past ); + } ); + + test( 'returns a page from all transactions when there are more than the page limit', () => { + const testState = cloneDeep( state ); + const pageSize = testState.billingTransactions.items.past.length - 1; + const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); + expect( result ).toEqual( state.billingTransactions.items.past.slice( 0, pageSize ) ); + } ); + + test( 'returns the first page of transactions when no page is specified', () => { + const testState = cloneDeep( state ); + const pageSize = testState.billingTransactions.items.past.length - 1; + const result = paginateTransactions( testState.billingTransactions.items.past, null, pageSize ); + expect( result ).toEqual( state.billingTransactions.items.past.slice( 0, pageSize ) ); + } ); + + test( 'returns the second page of transactions when the second page is specified', () => { + const testState = cloneDeep( state ); + const result = paginateTransactions( testState.billingTransactions.items.past, 2, 3 ); + expect( result.length ).toEqual( 3 ); + expect( result ).toEqual( state.billingTransactions.items.past.slice( 3, 6 ) ); + expect( result[ 0 ].date ).toEqual( '2018-03-15T10:39:27' ); + expect( result[ 1 ].date ).toEqual( '2018-03-13T16:10:45' ); + expect( result[ 2 ].date ).toEqual( '2018-01-10T14:24:38' ); + } ); + + test( 'returns an abbreviated last page of transactions when the last page is specified', () => { + const testState = cloneDeep( state ); + const result = paginateTransactions( testState.billingTransactions.items.past, 4, 3 ); + expect( result.length ).toEqual( 1 ); + expect( result ).toEqual( state.billingTransactions.items.past.slice( 9, 10 ) ); + expect( result[ 0 ].date ).toEqual( '2017-01-01T00:00:00' ); + } ); +} ); diff --git a/client/me/purchases/billing-history-data-view/test/tax.tsx b/client/me/purchases/billing-history-data-view/test/tax.tsx new file mode 100644 index 0000000000000..81bbca1d8938f --- /dev/null +++ b/client/me/purchases/billing-history-data-view/test/tax.tsx @@ -0,0 +1,272 @@ +/** + * @jest-environment jsdom + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { + countryList, + createTestReduxStore, + mockGetSupportedCountriesEndpoint, +} from 'calypso/my-sites/checkout/src/test/util'; +import { + BillingTransaction, + BillingTransactionItem, +} from 'calypso/state/billing-transactions/types'; +import { TransactionAmount, transactionIncludesTax } from '../utils'; + +const mockTransaction: BillingTransaction = { + currency: 'USD', + address: '', + amount: '', + amount_integer: 0, + tax_country_code: '', + cc_email: '', + cc_name: '', + cc_num: '', + cc_type: '', + credit: '', + date: '', + desc: '', + icon: '', + id: '', + items: [], + org: '', + pay_part: '', + pay_ref: '', + service: '', + subtotal: '', + subtotal_integer: 0, + support: '', + tax: '', + tax_integer: 0, + url: '', +}; + +const mockItem: BillingTransactionItem = { + id: '', + type: '', + type_localized: '', + domain: '', + site_id: '', + subtotal: '', + subtotal_integer: 0, + tax_integer: 0, + amount_integer: 0, + tax: '', + amount: '', + raw_subtotal: 0, + raw_tax: 0, + raw_amount: 0, + currency: '', + licensed_quantity: null, + new_quantity: null, + product: '', + product_slug: '', + variation: '', + variation_slug: '', + months_per_renewal_interval: 0, + wpcom_product_slug: '', + cost_overrides: [], +}; + +describe( 'transactionIncludesTax', () => { + test( 'returns true for a transaction with tax', () => { + const transaction: BillingTransaction = { + ...mockTransaction, + subtotal: '$36.00', + tax: '$2.48', + amount: '$38.48', + subtotal_integer: 3600, + tax_integer: 248, + amount_integer: 3848, + items: [ + { + ...mockItem, + raw_tax: 2.48, + tax_integer: 248, + }, + ], + }; + + expect( transactionIncludesTax( transaction ) ).toBe( true ); + } ); + + test( 'returns false for a transaction without tax values', () => { + const transaction = { + ...mockTransaction, + subtotal: '$36.00', + amount: '$38.48', + subtotal_integer: 3600, + amount_integer: 3848, + items: [ + { + ...mockItem, + }, + ], + }; + + expect( transactionIncludesTax( transaction ) ).toBe( false ); + } ); + + test( 'returns false for a transaction with zero tax values', () => { + const transaction = { + ...mockTransaction, + subtotal: '$36.00', + tax: '$0.00', + amount: '$38.48', + subtotal_integer: 3600, + amount_integer: 3848, + items: [ + { + ...mockItem, + raw_tax: 0, + }, + ], + }; + expect( transactionIncludesTax( transaction ) ).toBe( false ); + } ); + + test( 'returns false for a transaction without zero tax values in another currency', () => { + const transaction = { + ...mockTransaction, + currency: 'EUR', + subtotal: '€36.00', + tax: '€0.00', + amount: '€38.48', + subtotal_integer: 3600, + amount_integer: 3848, + items: [ + { + ...mockItem, + raw_tax: 0, + }, + ], + }; + + expect( transactionIncludesTax( transaction ) ).toBe( false ); + } ); +} ); + +test( 'tax shown if available', async () => { + const transaction = { + ...mockTransaction, + subtotal: '$36.00', + tax: '$2.48', + amount: '$38.48', + subtotal_integer: 3600, + tax_integer: 248, + amount_integer: 3848, + items: [ + { + ...mockItem, + raw_tax: 2.48, + tax_integer: 248, + }, + ], + }; + const store = createTestReduxStore(); + const queryClient = new QueryClient(); + mockGetSupportedCountriesEndpoint( countryList ); + + render( + + + + + + ); + expect( await screen.findByText( /tax/ ) ).toBeInTheDocument(); +} ); + +test( 'tax includes', async () => { + const transaction = { + ...mockTransaction, + subtotal: '$36.00', + tax: '$2.48', + amount: '$38.48', + subtotal_integer: 3600, + tax_integer: 248, + amount_integer: 3848, + items: [ + { + ...mockItem, + raw_tax: 2.48, + tax_integer: 248, + }, + ], + }; + + const store = createTestReduxStore(); + const queryClient = new QueryClient(); + mockGetSupportedCountriesEndpoint( countryList ); + + render( + + + + + + ); + expect( await screen.findByText( `(includes ${ transaction.tax } tax)` ) ).toBeInTheDocument(); +} ); + +test( 'tax includes with localized tax name', async () => { + const transaction = { + ...mockTransaction, + subtotal: '$36.00', + tax: '$2.48', + amount: '$38.48', + tax_country_code: 'GB', + subtotal_integer: 3600, + tax_integer: 248, + amount_integer: 3848, + items: [ + { + ...mockItem, + raw_tax: 2.48, + tax_integer: 248, + }, + ], + }; + + const store = createTestReduxStore(); + const queryClient = new QueryClient(); + mockGetSupportedCountriesEndpoint( countryList ); + + render( + + + + + + ); + expect( await screen.findByText( `(includes ${ transaction.tax } VAT)` ) ).toBeInTheDocument(); +} ); + +test( 'tax hidden if not available', async () => { + const transaction = { + ...mockTransaction, + subtotal: '$36.00', + tax: '$0.00', + amount: '$36.00', + subtotal_integer: 3600, + amount_integer: 3600, + items: [ { ...mockItem } ], + }; + + const store = createTestReduxStore(); + const queryClient = new QueryClient(); + mockGetSupportedCountriesEndpoint( countryList ); + + render( + + + + + + ); + expect( await screen.findByText( `$36` ) ).toBeInTheDocument(); + expect( screen.queryByText( `(includes ${ transaction.tax } VAT)` ) ).not.toBeInTheDocument(); +} ); diff --git a/client/me/purchases/billing-history-data-view/test/utils.ts b/client/me/purchases/billing-history-data-view/test/utils.ts new file mode 100644 index 0000000000000..b73c8785c3c51 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/test/utils.ts @@ -0,0 +1,354 @@ +import deepFreeze from 'deep-freeze'; +import { groupDomainProducts } from '../utils'; + +const ident = ( x ) => x; + +describe( 'utils', () => { + describe( '#groupDomainProducts()', () => { + test( 'should return non-domain items unchanged', () => { + const items = deepFreeze( [ { foo: 'bar', product_slug: 'foobar' } ] ); + const result = groupDomainProducts( items, ident ); + expect( result ).toEqual( items ); + } ); + + test( 'should return a single domain item unchanged', () => { + const items = deepFreeze( [ + { foo: 'bar', product_slug: 'foobar' }, + { product_slug: 'wp-domains', domain: 'foo.com', variation_slug: 'none' }, + ] ); + const expected = [ + { foo: 'bar', product_slug: 'foobar' }, + { + product_slug: 'wp-domains', + variation_slug: 'none', + domain: 'foo.com', + }, + ]; + const result = groupDomainProducts( items, ident ); + expect( result ).toEqual( expected ); + } ); + + test( 'should not group domain items with different domains', () => { + const items = deepFreeze( [ + { foo: 'bar', product_slug: 'foobar' }, + { + id: '2', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + cost_overrides: [], + }, + { + id: '3', + product_slug: 'wp-domains', + domain: 'bar.com', + variation_slug: 'wp-private-registration', + cost_overrides: [], + }, + ] ); + const expected = [ + { foo: 'bar', product_slug: 'foobar' }, + { + id: '2', + product_slug: 'wp-domains', + variation_slug: 'wp-private-registration', + domain: 'foo.com', + cost_overrides: [], + }, + { + id: '3', + product_slug: 'wp-domains', + variation_slug: 'wp-private-registration', + domain: 'bar.com', + cost_overrides: [], + }, + ]; + const result = groupDomainProducts( items, ident ); + expect( result ).toEqual( expected ); + expect( result.length ).toEqual( 3 ); + } ); + + test( 'should only return one domain item of multiple with the same domain', () => { + const items = deepFreeze( [ + { foo: 'bar', product_slug: 'foobar' }, + { + id: '2', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + cost_overrides: [], + }, + { + id: '3', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + cost_overrides: [], + }, + ] ); + const result = groupDomainProducts( items, ident ); + expect( result.length ).toEqual( 2 ); + } ); + + test( 'should sum the prices for multiple items with the same domain', () => { + const items = deepFreeze( [ + { foo: 'bar', product_slug: 'foobar' }, + { + id: '1', + product_slug: 'wp-domains', + domain: 'bar.com', + variation_slug: 'none', + currency: 'USD', + raw_amount: 2, + amount_integer: 200, + subtotal_integer: 210, + tax_integer: 10, + cost_overrides: [], + }, + { + id: '2', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'none', + currency: 'USD', + raw_amount: 3, + amount_integer: 300, + subtotal_integer: 310, + tax_integer: 10, + cost_overrides: [], + }, + { + id: '3', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + currency: 'USD', + raw_amount: 7, + amount_integer: 700, + subtotal_integer: 711, + tax_integer: 11, + cost_overrides: [], + }, + { + id: '4', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + currency: 'USD', + raw_amount: 9, + amount_integer: 900, + subtotal_integer: 912, + tax_integer: 12, + cost_overrides: [], + }, + ] ); + const result = groupDomainProducts( items, ident ); + expect( result[ 1 ].raw_amount ).toEqual( 2 ); + expect( result[ 1 ].amount_integer ).toEqual( 200 ); + expect( result[ 1 ].subtotal_integer ).toEqual( 210 ); + expect( result[ 1 ].tax_integer ).toEqual( 10 ); + expect( result[ 2 ].raw_amount ).toEqual( 19 ); + expect( result[ 2 ].amount_integer ).toEqual( 1900 ); + expect( result[ 2 ].subtotal_integer ).toEqual( 1933 ); + expect( result[ 2 ].tax_integer ).toEqual( 33 ); + } ); + + test( 'should sum the cost_overrides for multiple items with the same domain', () => { + const items = deepFreeze( [ + { foo: 'bar', product_slug: 'foobar' }, + { + id: '1', + product_slug: 'wp-domains', + domain: 'bar.com', + variation_slug: 'none', + currency: 'USD', + raw_amount: 2, + amount_integer: 200, + subtotal_integer: 210, + tax_integer: 10, + cost_overrides: [ + { + id: 'v12345', + human_readable_reason: 'Price change', + override_code: 'test-override', + does_override_original_cost: false, + old_price_integer: 100, + new_price_integer: 200, + }, + ], + }, + { + id: '2', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'none', + currency: 'USD', + raw_amount: 3, + amount_integer: 300, + subtotal_integer: 310, + tax_integer: 10, + cost_overrides: [ + { + id: 'v12345', + human_readable_reason: 'Price change', + override_code: 'test-override', + does_override_original_cost: false, + old_price_integer: 100, + new_price_integer: 200, + }, + { + id: 'v12347', + human_readable_reason: 'Price change 2', + override_code: 'test-override-2', + does_override_original_cost: false, + old_price_integer: 200, + new_price_integer: 300, + }, + ], + }, + { + id: '3', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + currency: 'USD', + raw_amount: 7, + amount_integer: 700, + subtotal_integer: 711, + tax_integer: 11, + cost_overrides: [ + { + id: 'v12345', + human_readable_reason: 'Price change', + override_code: 'test-override', + does_override_original_cost: false, + old_price_integer: 101, + new_price_integer: 301, + }, + { + id: 'v12346', + human_readable_reason: 'Price change 3', + override_code: 'test-override-3', + does_override_original_cost: false, + old_price_integer: 301, + new_price_integer: 700, + }, + ], + }, + { + id: '4', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + currency: 'USD', + raw_amount: 9, + amount_integer: 900, + subtotal_integer: 912, + tax_integer: 12, + cost_overrides: [ + { + id: 'v12345', + human_readable_reason: 'Price change', + override_code: 'test-override', + does_override_original_cost: false, + old_price_integer: 102, + new_price_integer: 900, + }, + ], + }, + ] ); + const result = groupDomainProducts( items, ident ); + expect( result[ 1 ].cost_overrides ).toEqual( [ + { + id: 'v12345', + human_readable_reason: 'Price change', + override_code: 'test-override', + does_override_original_cost: false, + old_price_integer: 100, + new_price_integer: 200, + }, + ] ); + expect( result[ 2 ].cost_overrides ).toEqual( [ + { + id: 'v12345', + human_readable_reason: 'Price change', + override_code: 'test-override', + does_override_original_cost: false, + old_price_integer: 303, + new_price_integer: 1401, + }, + { + id: 'v12347', + human_readable_reason: 'Price change 2', + override_code: 'test-override-2', + does_override_original_cost: false, + old_price_integer: 200, + new_price_integer: 300, + }, + { + id: 'v12346', + human_readable_reason: 'Price change 3', + override_code: 'test-override-3', + does_override_original_cost: false, + old_price_integer: 301, + new_price_integer: 700, + }, + ] ); + } ); + + test( 'should include the formatted, summed raw_amount as amount for multiple items with the same domain', () => { + const items = deepFreeze( [ + { foo: 'bar', product_slug: 'foobar' }, + { + id: '1', + product_slug: 'wp-domains', + domain: 'bar.com', + variation_slug: 'none', + amount: '$2.00', + currency: 'USD', + raw_amount: 2, + amount_integer: 200, + cost_overrides: [], + }, + { + id: '2', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'none', + amount: '$3.00', + currency: 'USD', + raw_amount: 3, + amount_integer: 300, + cost_overrides: [], + }, + { + id: '3', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + amount: '$7.00', + currency: 'USD', + raw_amount: 7, + amount_integer: 700, + cost_overrides: [], + }, + { + id: '4', + product_slug: 'wp-domains', + domain: 'foo.com', + variation_slug: 'wp-private-registration', + amount: '$9.00', + currency: 'USD', + raw_amount: 9, + amount_integer: 900, + cost_overrides: [], + }, + ] ); + const result = groupDomainProducts( items, ident ); + expect( result[ 1 ].amount ).toEqual( '$2.00' ); + expect( result[ 1 ].amount_integer ).toEqual( 200 ); + expect( result[ 2 ].amount ).toEqual( '$19' ); + expect( result[ 2 ].amount_integer ).toEqual( 1900 ); + } ); + } ); +} ); diff --git a/client/me/purchases/billing-history-data-view/utils.tsx b/client/me/purchases/billing-history-data-view/utils.tsx new file mode 100644 index 0000000000000..0c435617853a2 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/utils.tsx @@ -0,0 +1,344 @@ +import { + getPlanTermLabel, + isDIFMProduct, + isGoogleWorkspace, + isTitanMail, + isTieredVolumeSpaceAddon, +} from '@automattic/calypso-products'; +import formatCurrency from '@automattic/format-currency'; +import { LocalizeProps, useTranslate } from 'i18n-calypso'; +import { Fragment } from 'react'; +import { useTaxName } from 'calypso/my-sites/checkout/src/hooks/use-country-list'; +import { + BillingTransaction, + BillingTransactionItem, + ReceiptCostOverride, +} from 'calypso/state/billing-transactions/types'; + +interface GroupedDomainProduct { + product: BillingTransactionItem; + groupCount: number; +} + +export const groupDomainProducts = ( + originalItems: BillingTransactionItem[], + translate: LocalizeProps[ 'translate' ] +) => { + const domainProducts: BillingTransactionItem[] = []; + const otherProducts: BillingTransactionItem[] = []; + originalItems.forEach( ( item ) => { + if ( item.product_slug === 'wp-domains' ) { + domainProducts.push( item ); + } else { + otherProducts.push( item ); + } + } ); + + const groupedDomainProductsMap = domainProducts.reduce< + Map< BillingTransactionItem[ 'domain' ], GroupedDomainProduct > + >( ( groups, product ) => { + const existingGroup = groups.get( product.domain ); + if ( existingGroup ) { + const mergedOverrides: ReceiptCostOverride[] = []; + existingGroup.product.cost_overrides = existingGroup.product.cost_overrides.map( + ( existingGroupOverride ) => { + const productOverride = product.cost_overrides.find( + ( override ) => override.override_code === existingGroupOverride.override_code + ); + if ( productOverride ) { + mergedOverrides.push( productOverride ); + return { + ...existingGroupOverride, + new_price_integer: + existingGroupOverride.new_price_integer + productOverride.new_price_integer, + old_price_integer: + existingGroupOverride.old_price_integer + productOverride.old_price_integer, + }; + } + return existingGroupOverride; + } + ); + product.cost_overrides.forEach( ( override ) => { + if ( ! mergedOverrides.some( ( merged ) => merged.id === override.id ) ) { + existingGroup.product.cost_overrides.push( override ); + } + } ); + existingGroup.product.raw_amount += product.raw_amount; + existingGroup.product.amount_integer += product.amount_integer; + existingGroup.product.subtotal_integer += product.subtotal_integer; + existingGroup.product.tax_integer += product.tax_integer; + existingGroup.groupCount++; + } else { + const newGroup = { + product: { ...product }, + groupCount: 1, + }; + groups.set( product.domain, newGroup ); + } + + return groups; + }, new Map() ); + + const groupedDomainProducts: BillingTransactionItem[] = []; + + groupedDomainProductsMap.forEach( ( value ) => { + if ( value.groupCount === 1 ) { + groupedDomainProducts.push( value.product ); + return; + } + groupedDomainProducts.push( { + ...value.product, + amount: formatCurrency( value.product.amount_integer, value.product.currency, { + isSmallestUnit: true, + stripZeros: true, + } ), + variation: translate( 'Domain Registration' ), + } ); + } ); + + return [ ...otherProducts, ...groupedDomainProducts ]; +}; + +export function transactionIncludesTax( transaction: BillingTransaction ) { + if ( ! transaction || ! transaction.tax_integer ) { + return false; + } + + // Consider the whole transaction to include tax if any item does + return transaction.items.some( ( item ) => item.tax_integer > 0 ); +} + +export function TransactionAmount( { + transaction, +}: { + transaction: BillingTransaction; +} ): JSX.Element { + const translate = useTranslate(); + const taxName = useTaxName( transaction.tax_country_code ); + + if ( ! transactionIncludesTax( transaction ) ) { + return ( + <> + { formatCurrency( transaction.amount_integer, transaction.currency, { + isSmallestUnit: true, + stripZeros: true, + } ) } + + ); + } + + const includesTaxString = taxName + ? translate( '(includes %(taxAmount)s %(taxName)s)', { + args: { + taxAmount: formatCurrency( transaction.tax_integer, transaction.currency, { + isSmallestUnit: true, + stripZeros: true, + } ), + taxName, + }, + comment: + 'taxAmount is a localized price, like $12.34 | taxName is a localized tax, like VAT or GST', + } ) + : translate( '(includes %(taxAmount)s tax)', { + args: { + taxAmount: formatCurrency( transaction.tax_integer, transaction.currency, { + isSmallestUnit: true, + stripZeros: true, + } ), + }, + comment: 'taxAmount is a localized price, like $12.34', + } ); + + return ( + +
    + { formatCurrency( transaction.amount_integer, transaction.currency, { + isSmallestUnit: true, + stripZeros: true, + } ) } +
    +
    { includesTaxString }
    +
    + ); +} + +function renderTransactionQuantitySummaryForMailboxes( + licensed_quantity: number, + new_quantity: number, + isRenewal: boolean, + isUpgrade: boolean, + translate: LocalizeProps[ 'translate' ] +) { + if ( isRenewal ) { + return translate( 'Renewal for %(quantity)d mailbox', 'Renewal for %(quantity)d mailboxes', { + args: { quantity: licensed_quantity }, + count: licensed_quantity, + comment: '%(quantity)d is number of mailboxes renewed', + } ); + } + + if ( isUpgrade ) { + return translate( + 'Purchase of %(quantity)d additional mailbox', + 'Purchase of %(quantity)d additional mailboxes', + { + args: { quantity: new_quantity }, + count: new_quantity, + comment: '%(quantity)d is additional number of mailboxes purchased', + } + ); + } + + return translate( 'Purchase of %(quantity)d mailbox', 'Purchase of %(quantity)d mailboxes', { + args: { quantity: licensed_quantity }, + count: licensed_quantity, + comment: '%(quantity)d is number of mailboxes purchased', + } ); +} + +function renderDIFMTransactionQuantitySummary( + licensed_quantity: number, + translate: LocalizeProps[ 'translate' ] +) { + return translate( + 'One-time fee includes %(quantity)d page', + 'One-time fee includes %(quantity)d pages', + { + args: { quantity: licensed_quantity }, + count: licensed_quantity, + comment: '%(quantity)d is number of pages included in the purchase of the DIFM service', + } + ); +} + +function renderSpaceAddOnquantitySummary( + licensed_quantity: number, + isRenewal: boolean, + translate: LocalizeProps[ 'translate' ] +) { + if ( isRenewal ) { + return translate( 'Renewal for %(quantity)d GB', { + args: { quantity: licensed_quantity }, + comment: '%(quantity)d is number of GBs renewed', + } ); + } + + return translate( 'Purchase of %(quantity)d GB', { + args: { quantity: licensed_quantity }, + comment: '%(quantity)d is number of GBs purchased', + } ); +} + +export function renderDomainTransactionVolumeSummary( + { volume, product_slug, type }: BillingTransactionItem, + translate: LocalizeProps[ 'translate' ] +) { + if ( ! volume ) { + return null; + } + + const isRenewal = 'recurring' === type; + + volume = parseInt( String( volume ) ); + + if ( 'wp-domains' !== product_slug ) { + return null; + } + + if ( isRenewal ) { + return translate( + 'Domain renewed for %(quantity)d year', + 'Domain renewed for %(quantity)d years', + { + args: { quantity: volume }, + count: volume, + comment: '%(quantity)d is the number of years the domain has been renewed for', + } + ); + } + + return translate( + 'Domain registered for %(quantity)d year', + 'Domain registered for %(quantity)d years', + { + args: { quantity: volume }, + count: volume, + comment: '%(quantity)d is number of years the domain has been registered for', + } + ); +} + +export function renderTransactionQuantitySummary( + { licensed_quantity, new_quantity, type, wpcom_product_slug }: BillingTransactionItem, + translate: LocalizeProps[ 'translate' ] +) { + if ( ! licensed_quantity ) { + return null; + } + + licensed_quantity = parseInt( String( licensed_quantity ) ); + new_quantity = parseInt( String( new_quantity ) ); + const product = { product_slug: wpcom_product_slug }; + const isRenewal = 'recurring' === type; + const isUpgrade = 'new purchase' === type && new_quantity > 0; + + if ( isGoogleWorkspace( product ) || isTitanMail( product ) ) { + return renderTransactionQuantitySummaryForMailboxes( + licensed_quantity, + new_quantity, + isRenewal, + isUpgrade, + translate + ); + } + + if ( isDIFMProduct( product ) ) { + return renderDIFMTransactionQuantitySummary( licensed_quantity, translate ); + } + + if ( isTieredVolumeSpaceAddon( product ) ) { + return renderSpaceAddOnquantitySummary( licensed_quantity, isRenewal, translate ); + } + + if ( isRenewal ) { + return translate( 'Renewal for %(quantity)d item', 'Renewal for %(quantity)d items', { + args: { quantity: licensed_quantity }, + count: licensed_quantity, + comment: '%(quantity)d is number of items renewed', + } ); + } + + if ( isUpgrade ) { + return translate( + 'Purchase of %(quantity)d additional item', + 'Purchase of %(quantity)d additional items', + { + args: { quantity: new_quantity }, + count: new_quantity, + comment: '%(quantity)d is additional number of items purchased', + } + ); + } + + return translate( 'Purchase of %(quantity)d item', 'Purchase of %(quantity)d items', { + args: { quantity: licensed_quantity }, + count: licensed_quantity, + comment: '%(quantity)d is number of items purchased', + } ); +} + +export function getTransactionTermLabel( + transaction: BillingTransactionItem, + translate: LocalizeProps[ 'translate' ] +) { + switch ( transaction.months_per_renewal_interval ) { + case 1: + return translate( 'Monthly subscription' ); + case 12: + return translate( 'Annual subscription' ); + case 24: + return translate( 'Two year subscription' ); + default: + return getPlanTermLabel( transaction.wpcom_product_slug, translate ); + } +} diff --git a/client/me/purchases/billing-history-data-view/vat-vendor-details.tsx b/client/me/purchases/billing-history-data-view/vat-vendor-details.tsx new file mode 100644 index 0000000000000..7d502922a7759 --- /dev/null +++ b/client/me/purchases/billing-history-data-view/vat-vendor-details.tsx @@ -0,0 +1,35 @@ +import { useTranslate } from 'i18n-calypso'; +import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; + +export function VatVendorDetails( { transaction }: { transaction: BillingTransaction } ) { + const translate = useTranslate(); + const vendorInfo = transaction.tax_vendor_info; + if ( ! vendorInfo ) { + return null; + } + + return ( +
  • + + { translate( 'Vendor %(taxName)s Details', { + args: { + taxName: Object.keys( vendorInfo.tax_name_and_vendor_id_array ).join( '/' ), + }, + comment: 'taxName is a localized tax, like VAT or GST', + } ) } + + + { vendorInfo.address.map( ( addressLine ) => ( +
    { addressLine }
    + ) ) } +
    + + { Object.entries( vendorInfo.tax_name_and_vendor_id_array ).map( ( [ taxName, taxID ] ) => ( +
    + { taxName } { taxID } +
    + ) ) } +
    +
  • + ); +} diff --git a/client/me/purchases/billing-history/main.tsx b/client/me/purchases/billing-history/main.tsx index 2b482415a4e9d..ec105ff2de638 100644 --- a/client/me/purchases/billing-history/main.tsx +++ b/client/me/purchases/billing-history/main.tsx @@ -9,6 +9,7 @@ import NavigationHeader from 'calypso/components/navigation-header'; import { useGeoLocationQuery } from 'calypso/data/geo/use-geolocation-query'; import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; import BillingHistoryList from 'calypso/me/purchases/billing-history/billing-history-list'; +import BillingHistoryListDataView from 'calypso/me/purchases/billing-history-data-view/billing-history-list'; import { vatDetails as vatDetailsPath, billingHistoryReceipt } from 'calypso/me/purchases/paths'; import PurchasesNavigation from 'calypso/me/purchases/purchases-navigation'; import titles from 'calypso/me/purchases/titles'; @@ -24,9 +25,19 @@ export function BillingHistoryContent( { siteId: number | null; getReceiptUrlFor: ( receiptId: string | number ) => string; } ) { + const useDataViewBillingHistoryList = config.isEnabled( 'purchases/billing-history-data-view' ); + return ( - + { useDataViewBillingHistoryList ? ( + + ) : ( + + ) } ); } @@ -69,7 +80,7 @@ function BillingHistory() { } ) } /> - + diff --git a/config/development.json b/config/development.json index c42412220b888..2135185a46591 100644 --- a/config/development.json +++ b/config/development.json @@ -192,6 +192,7 @@ "press-this": true, "promote-post/widget-i2": true, "publicize-preview": true, + "purchases/billing-history-data-view": true, "purchases/new-payment-methods": true, "push-notifications": true, "reader": true, diff --git a/config/horizon.json b/config/horizon.json index 2d2bd79ee4d1d..bae19bf09a4fe 100644 --- a/config/horizon.json +++ b/config/horizon.json @@ -120,6 +120,7 @@ "press-this": true, "promote-post/widget-i2": true, "publicize-preview": true, + "purchases/billing-history-data-view": false, "purchases/new-payment-methods": true, "reader": true, "reader/first-posts-stream": true, diff --git a/config/production.json b/config/production.json index 244b24a7d0b06..200a3a53600c4 100644 --- a/config/production.json +++ b/config/production.json @@ -162,6 +162,7 @@ "press-this": true, "promote-post/widget-i2": true, "publicize-preview": true, + "purchases/billing-history-data-view": false, "purchases/new-payment-methods": true, "push-notifications": true, "reader": true, diff --git a/config/stage.json b/config/stage.json index 3c1842468985c..9b83727ed4600 100644 --- a/config/stage.json +++ b/config/stage.json @@ -160,6 +160,7 @@ "press-this": true, "promote-post/widget-i2": true, "publicize-preview": true, + "purchases/billing-history-data-view": false, "purchases/new-payment-methods": true, "push-notifications": true, "reader": true, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index ae15d727dd8f3..513ef3fb91519 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -157,6 +157,7 @@ "press-this": true, "promote-post/widget-i2": true, "publicize-preview": true, + "purchases/billing-history-data-view": true, "purchases/new-payment-methods": true, "reader": true, "reader/first-posts-stream": true, From 66d271a2a62fc778069a905aba083cf7d9f352d3 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 19 Dec 2024 09:26:47 -0600 Subject: [PATCH 02/45] Use page.redirect() for view receipt action --- .../billing-history-data-view/billing-history-list.tsx | 3 ++- client/me/purchases/purchases-navigation/index.jsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index be89c937d1b64..0303bbd09f180 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -1,3 +1,4 @@ +import page from '@automattic/calypso-router'; import { DataViews } from '@wordpress/dataviews'; import { localize, LocalizeProps } from 'i18n-calypso'; import moment from 'moment'; @@ -143,7 +144,7 @@ class BillingHistoryListDataView extends Component< label: 'View receipt', callback: ( items: BillingTransaction[] ) => { const item = items[ 0 ]; - window.location.href = getReceiptUrlFor( item.id ); + page.redirect( getReceiptUrlFor( item.id ) ); }, }, { diff --git a/client/me/purchases/purchases-navigation/index.jsx b/client/me/purchases/purchases-navigation/index.jsx index 84d4d92f9d8f8..34cd36fd371f0 100644 --- a/client/me/purchases/purchases-navigation/index.jsx +++ b/client/me/purchases/purchases-navigation/index.jsx @@ -1,3 +1,4 @@ +import config from '@automattic/calypso-config'; import { useTranslate } from 'i18n-calypso'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; @@ -12,6 +13,7 @@ import { setQuery } from 'calypso/state/billing-transactions/ui/actions'; export default function PurchasesNavigation( { section } ) { const translate = useTranslate(); const dispatch = useDispatch(); + const useDataViewBillingHistoryList = config.isEnabled( 'purchases/billing-history-data-view' ); return ( @@ -29,7 +31,7 @@ export default function PurchasesNavigation( { section } ) { - { section === 'billingHistory' && ( + { section === 'billingHistory' && ! useDataViewBillingHistoryList && ( Date: Thu, 19 Dec 2024 15:20:29 -0600 Subject: [PATCH 03/45] Moves to hybrid approach to layout --- .../billing-history-list.tsx | 38 ++++++++- .../billing-history-data-view/style.scss | 82 +++++++++++++++++++ .../billing-history-data-view/utils.tsx | 6 +- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index 0303bbd09f180..8ae34fea64136 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -1,4 +1,6 @@ +/* eslint-disable prettier/prettier */ import page from '@automattic/calypso-router'; +import { Gridicon } from '@automattic/components'; import { DataViews } from '@wordpress/dataviews'; import { localize, LocalizeProps } from 'i18n-calypso'; import moment from 'moment'; @@ -76,7 +78,8 @@ class BillingHistoryListDataView extends Component< } } fields={ this.getFields() } view={ this.getView() } - search={ false } + search + searchLabel="Search receipts" onChangeView={ ( newView: { page?: number } ) => { const newPage = typeof newView.page === 'number' ? newView.page : 1; if ( newView.page !== this.props.page ) { @@ -110,25 +113,48 @@ class BillingHistoryListDataView extends Component< }; getFields = () => { + const SERVICES = [ + { value: 'WordPress.com', label: 'WordPress.com' }, + { value: 'Jetpack', label: 'Jetpack' }, + { value: 'WooCommerce', label: 'WooCommerce' }, + ]; + return [ { id: 'date', label: 'Date', + type: 'datetime', + enableGlobalSearch: true, + enableHiding: false, + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.date; + }, render: ( { item }: { item: BillingTransaction } ) => { - const date = this.props.moment( item.date ).format( 'll' ); - return ; + return ; }, }, { id: 'service', - label: 'Service', + label: 'Summary', + type: 'text', + elements: SERVICES, + enableGlobalSearch: true, + enableHiding: false, render: ( { item }: { item: BillingTransaction } ) => { return
    { this.serviceName( item ) }
    ; }, + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.service; + }, + filterBy: { + operators: [ 'isAny', 'is', 'isAny' ], + }, }, { id: 'amount', label: 'Amount', + type: 'text', + enableGlobalSearch: true, render: ( { item }: { item: BillingTransaction } ) => { return ; }, @@ -142,6 +168,8 @@ class BillingHistoryListDataView extends Component< { id: 'view-receipt', label: 'View receipt', + isPrimary: true, + icon: , callback: ( items: BillingTransaction[] ) => { const item = items[ 0 ]; page.redirect( getReceiptUrlFor( item.id ) ); @@ -150,6 +178,8 @@ class BillingHistoryListDataView extends Component< { id: 'email-receipt', label: 'Email receipt', + isPrimary: true, + icon: , callback: ( items: BillingTransaction[] ) => { const item = items[ 0 ]; this.recordClickEvent( 'Email Receipt in Billing History' ); diff --git a/client/me/purchases/billing-history-data-view/style.scss b/client/me/purchases/billing-history-data-view/style.scss index 1fd0ec9f9ead7..79c1adc6ae195 100644 --- a/client/me/purchases/billing-history-data-view/style.scss +++ b/client/me/purchases/billing-history-data-view/style.scss @@ -1,7 +1,31 @@ .billing-history { + .navigation-header::after { + display: none; + } + .dataviews-wrapper { width: 100%; + .dataviews-view-table__actions-column { + .components-button.is-compact.has-icon:not(.dataviews-all-actions-button) { + .gridicon { + opacity: 0; + transition: opacity 0.15s ease-in-out; + } + } + } + + .dataviews-view-table__row:hover { + background-color: rgba( 0, 0, 0, 0.05 ); + .dataviews-view-table__actions-column { + .components-button.is-compact.has-icon { + .gridicon { + opacity: 1; + } + } + } + } + .dataviews-view-table__row { background: var(--studio-white); opacity: 1; @@ -13,6 +37,7 @@ vertical-align: middle; padding: 12px; text-align: left; + text-transform: uppercase; } td { @@ -30,6 +55,44 @@ small { display: block; } + + .dataviews-filters__container-visibility-toggle { + position: relative; + } + + .dataviews-filters-toggle__count { + position: absolute; + top: 0; + right: 0; + transform: translate(50%, -50%); + background: var(--wp-admin-theme-color, #3858e9); + height: 16px; + min-width: 16px; + line-height: 16px; + padding: 0 4px; + text-align: center; + border-radius: 8px; + font-size: 11px; + outline: var(--wp-admin-border-width-focus) solid #fff; + color: #fff; + box-sizing: border-box; + } + } + + .dataviews__view-actions { + margin-top: 1em; + } + + .section-nav { + box-shadow: none; + } + + .section-nav-group { + border-bottom: 1px solid var(--color-neutral-5); + } + + .card.billing-history__receipts { + box-shadow: none; } &__email-button { @@ -40,6 +103,15 @@ font-weight: normal; padding: 0; text-decoration: underline; + display: flex; + align-items: center; + gap: 4px; + + .gridicon { + display: inline-block; + vertical-align: middle; + fill: currentColor; + } &:hover { color: var(--color-link-dark); @@ -51,3 +123,13 @@ gap: 1em; } } + +@media screen and (min-width: 782px) { + body.is-section-me .navigation-header::after { + display: none; + } +} + +body.is-section-me div.layout.is-global-sidebar-visible .layout__primary .billing-history.main > * { + max-width: 100%; +} diff --git a/client/me/purchases/billing-history-data-view/utils.tsx b/client/me/purchases/billing-history-data-view/utils.tsx index 0c435617853a2..ac05b779f88a3 100644 --- a/client/me/purchases/billing-history-data-view/utils.tsx +++ b/client/me/purchases/billing-history-data-view/utils.tsx @@ -121,7 +121,7 @@ export function TransactionAmount( { <> { formatCurrency( transaction.amount_integer, transaction.currency, { isSmallestUnit: true, - stripZeros: true, + stripZeros: false, } ) } ); @@ -132,7 +132,7 @@ export function TransactionAmount( { args: { taxAmount: formatCurrency( transaction.tax_integer, transaction.currency, { isSmallestUnit: true, - stripZeros: true, + stripZeros: false, } ), taxName, }, @@ -143,7 +143,7 @@ export function TransactionAmount( { args: { taxAmount: formatCurrency( transaction.tax_integer, transaction.currency, { isSmallestUnit: true, - stripZeros: true, + stripZeros: false, } ), }, comment: 'taxAmount is a localized price, like $12.34', From 2847e3cd19acbf1a271a09c17b66250e563bfb2d Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 20 Dec 2024 10:12:23 -0600 Subject: [PATCH 04/45] Fix type errors and refactor --- .../billing-history-list.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index 8ae34fea64136..49cbfa6183324 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -1,7 +1,7 @@ /* eslint-disable prettier/prettier */ import page from '@automattic/calypso-router'; import { Gridicon } from '@automattic/components'; -import { DataViews } from '@wordpress/dataviews'; +import { DataViews, Operator } from '@wordpress/dataviews'; import { localize, LocalizeProps } from 'i18n-calypso'; import moment from 'moment'; import { Component } from 'react'; @@ -64,6 +64,13 @@ class BillingHistoryListDataView extends Component< this.props.setPage( 'past', page ); }; + onChangeView = ( newView: { page?: number } ) => { + const newPage = typeof newView.page === 'number' ? newView.page : 1; + if ( newView.page !== this.props.page ) { + this.props.setPage( 'past', newPage ); + } + }; + render() { const transactions = this.props.transactions || []; @@ -80,12 +87,7 @@ class BillingHistoryListDataView extends Component< view={ this.getView() } search searchLabel="Search receipts" - onChangeView={ ( newView: { page?: number } ) => { - const newPage = typeof newView.page === 'number' ? newView.page : 1; - if ( newView.page !== this.props.page ) { - this.props.setPage( 'past', newPage ); - } - } } + onChangeView={ this.onChangeView } defaultLayouts={ { table: {} } } actions={ this.getActions() } isLoading={ false } @@ -123,7 +125,7 @@ class BillingHistoryListDataView extends Component< { id: 'date', label: 'Date', - type: 'datetime', + type: 'datetime' as const, enableGlobalSearch: true, enableHiding: false, getValue: ( { item }: { item: BillingTransaction } ) => { @@ -136,7 +138,7 @@ class BillingHistoryListDataView extends Component< { id: 'service', label: 'Summary', - type: 'text', + type: 'text' as const, elements: SERVICES, enableGlobalSearch: true, enableHiding: false, @@ -147,13 +149,13 @@ class BillingHistoryListDataView extends Component< return item.service; }, filterBy: { - operators: [ 'isAny', 'is', 'isAny' ], + operators: [ 'isAny', 'is', 'isAny' ] as Operator[], }, }, { id: 'amount', label: 'Amount', - type: 'text', + type: 'text' as const, enableGlobalSearch: true, render: ( { item }: { item: BillingTransaction } ) => { return ; @@ -192,14 +194,14 @@ class BillingHistoryListDataView extends Component< getView = () => { const { page, pageSize } = this.props; return { - type: 'table', + type: 'table' as const, search: '', filters: [], page: page, perPage: pageSize, sort: { field: 'date', - direction: 'desc', + direction: 'desc' as const, }, titleField: 'title', fields: [ 'date', 'service', 'amount', 'actions' ], From 39b9dd566d28e4c4aff1049906a9eb57aebb2c01 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 20 Dec 2024 10:18:23 -0600 Subject: [PATCH 05/45] Removes unused legacy code --- .../billing-history-list.tsx | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index 49cbfa6183324..4c4abcd82905b 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -26,7 +26,6 @@ import { TransactionAmount, renderTransactionQuantitySummary, } from './utils'; -import type { MouseEvent } from 'react'; import '@wordpress/dataviews/build-style/style.css'; import './style.scss'; @@ -60,10 +59,6 @@ class BillingHistoryListDataView extends Component< header: false, }; - onPageClick = ( page: number ) => { - this.props.setPage( 'past', page ); - }; - onChangeView = ( newView: { page?: number } ) => { const newPage = typeof newView.page === 'number' ? newView.page : 1; if ( newView.page !== this.props.page ) { @@ -227,53 +222,6 @@ class BillingHistoryListDataView extends Component< recordClickEvent = ( eventAction: string ) => { recordGoogleEvent( 'Me', eventAction ); }; - - handleReceiptLinkClick = () => { - return this.recordClickEvent( 'View Receipt in Billing History' ); - }; - - getEmailReceiptLinkClickHandler = ( receiptId: string ) => { - const { sendBillingReceiptEmail } = this.props; - - return ( event: MouseEvent< HTMLButtonElement > ) => { - event.preventDefault(); - this.recordClickEvent( 'Email Receipt in Billing History' ); - sendBillingReceiptEmail( receiptId ); - }; - }; - - renderEmailAction = ( receiptId: string ) => { - const { translate, sendingBillingReceiptEmail } = this.props; - - if ( sendingBillingReceiptEmail( receiptId ) ) { - return translate( 'Emailing receipt…' ); - } - - return ( - - ); - }; - - renderActions = ( transaction: BillingTransaction ) => { - const { translate, getReceiptUrlFor } = this.props; - - return ( -
    - ); - }; } function getIsSendingReceiptEmail( state: IAppState ) { From b17fdc4c0e4f42faad07c141d1bb1498e2b05a07 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 20 Dec 2024 11:01:17 -0600 Subject: [PATCH 06/45] Refactors to functional component --- .../billing-history-list.tsx | 325 +++++++++--------- 1 file changed, 161 insertions(+), 164 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index 4c4abcd82905b..f0efc9ff36d02 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -1,10 +1,9 @@ /* eslint-disable prettier/prettier */ -import page from '@automattic/calypso-router'; +import pageRedirect from '@automattic/calypso-router'; import { Gridicon } from '@automattic/components'; import { DataViews, Operator } from '@wordpress/dataviews'; import { localize, LocalizeProps } from 'i18n-calypso'; import moment from 'moment'; -import { Component } from 'react'; import { connect } from 'react-redux'; import { withLocalizedMoment } from 'calypso/components/localized-moment'; import { capitalPDangit } from 'calypso/lib/formatting'; @@ -30,6 +29,54 @@ import { import '@wordpress/dataviews/build-style/style.css'; import './style.scss'; +const SERVICES = [ + { value: 'WordPress.com', label: 'WordPress.com' }, + { value: 'Jetpack', label: 'Jetpack' }, + { value: 'WooCommerce', label: 'WooCommerce' }, +]; + +const recordClickEvent = ( eventAction: string ) => { + recordGoogleEvent( 'Me', eventAction ); +}; + +const serviceNameDescription = ( + transaction: BillingTransactionItem, + translate: LocalizeProps[ 'translate' ] +) => { + const plan = capitalPDangit( transaction.variation ); + const termLabel = getTransactionTermLabel( transaction, translate ); + return ( +
    + { plan } + { transaction.domain && { transaction.domain } } + { termLabel && { termLabel } } + { transaction.licensed_quantity && ( + { renderTransactionQuantitySummary( transaction, translate ) } + ) } +
    + ); +}; + +const serviceName = ( + transaction: BillingTransaction, + translate: LocalizeProps[ 'translate' ] +) => { + const [ transactionItem, ...moreTransactionItems ] = groupDomainProducts( + transaction.items, + translate + ); + + if ( moreTransactionItems.length > 0 ) { + return { translate( 'Multiple items' ) }; + } + + if ( transactionItem.product === transactionItem.variation ) { + return transactionItem.product; + } + + return serviceNameDescription( transactionItem, translate ); +}; + export interface BillingHistoryListProps { header?: boolean; siteId?: string | number | null; @@ -50,179 +97,129 @@ export interface BillingHistoryListConnectedProps { setPage: ( transactionType: string, page: number ) => void; } -class BillingHistoryListDataView extends Component< - BillingHistoryListProps & BillingHistoryListConnectedProps & LocalizeProps -> { - static displayName = 'BillingHistoryList'; - - static defaultProps = { - header: false, - }; - - onChangeView = ( newView: { page?: number } ) => { +type Props = BillingHistoryListProps & BillingHistoryListConnectedProps & LocalizeProps; + +const BillingHistoryListDataView: React.FC< Props > = ( { + getReceiptUrlFor, + page, + pageSize, + total, + transactions = [], + sendBillingReceiptEmail, + setPage, + translate, + moment, +} ) => { + const onChangeView = ( newView: { page?: number } ) => { const newPage = typeof newView.page === 'number' ? newView.page : 1; - if ( newView.page !== this.props.page ) { - this.props.setPage( 'past', newPage ); - } - }; - - render() { - const transactions = this.props.transactions || []; - - return ( -
    -
    - -
    -
    - ); - } - - serviceName = ( transaction: BillingTransaction ) => { - const [ transactionItem, ...moreTransactionItems ] = groupDomainProducts( - transaction.items, - this.props.translate - ); - - if ( moreTransactionItems.length > 0 ) { - return { this.props.translate( 'Multiple items' ) }; - } - - if ( transactionItem.product === transactionItem.variation ) { - return transactionItem.product; + if ( newView.page !== page ) { + setPage( 'past', newPage ); } - - return this.serviceNameDescription( transactionItem ); }; - getFields = () => { - const SERVICES = [ - { value: 'WordPress.com', label: 'WordPress.com' }, - { value: 'Jetpack', label: 'Jetpack' }, - { value: 'WooCommerce', label: 'WooCommerce' }, - ]; - - return [ - { - id: 'date', - label: 'Date', - type: 'datetime' as const, - enableGlobalSearch: true, - enableHiding: false, - getValue: ( { item }: { item: BillingTransaction } ) => { - return item.date; - }, - render: ( { item }: { item: BillingTransaction } ) => { - return ; - }, + const getFields = () => [ + { + id: 'date', + label: 'Date', + type: 'datetime' as const, + enableGlobalSearch: true, + enableHiding: false, + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.date; }, - { - id: 'service', - label: 'Summary', - type: 'text' as const, - elements: SERVICES, - enableGlobalSearch: true, - enableHiding: false, - render: ( { item }: { item: BillingTransaction } ) => { - return
    { this.serviceName( item ) }
    ; - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - return item.service; - }, - filterBy: { - operators: [ 'isAny', 'is', 'isAny' ] as Operator[], - }, + render: ( { item }: { item: BillingTransaction } ) => { + return ; }, - { - id: 'amount', - label: 'Amount', - type: 'text' as const, - enableGlobalSearch: true, - render: ( { item }: { item: BillingTransaction } ) => { - return ; - }, + }, + { + id: 'service', + label: 'Summary', + type: 'text' as const, + elements: SERVICES, + enableGlobalSearch: true, + enableHiding: false, + render: ( { item }: { item: BillingTransaction } ) => { + return
    { serviceName( item, translate ) }
    ; }, - ]; - }; - - getActions = () => { - const { getReceiptUrlFor, sendBillingReceiptEmail } = this.props; - return [ - { - id: 'view-receipt', - label: 'View receipt', - isPrimary: true, - icon: , - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - page.redirect( getReceiptUrlFor( item.id ) ); - }, + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.service; }, - { - id: 'email-receipt', - label: 'Email receipt', - isPrimary: true, - icon: , - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - this.recordClickEvent( 'Email Receipt in Billing History' ); - sendBillingReceiptEmail( item.id ); - }, + filterBy: { + operators: [ 'isAny', 'is', 'isAny' ] as Operator[], }, - ]; - }; - - getView = () => { - const { page, pageSize } = this.props; - return { - type: 'table' as const, - search: '', - filters: [], - page: page, - perPage: pageSize, - sort: { - field: 'date', - direction: 'desc' as const, + }, + { + id: 'amount', + label: 'Amount', + type: 'text' as const, + enableGlobalSearch: true, + render: ( { item }: { item: BillingTransaction } ) => { + return ; }, - titleField: 'title', - fields: [ 'date', 'service', 'amount', 'actions' ], - layout: {}, - }; - }; - - serviceNameDescription = ( transaction: BillingTransactionItem ) => { - const plan = capitalPDangit( transaction.variation ); - const termLabel = getTransactionTermLabel( transaction, this.props.translate ); - return ( -
    - { plan } - { transaction.domain && { transaction.domain } } - { termLabel && { termLabel } } - { transaction.licensed_quantity && ( - { renderTransactionQuantitySummary( transaction, this.props.translate ) } - ) } + }, + ]; + + const getView = () => ( { + type: 'table' as const, + search: '', + filters: [], + page, + perPage: pageSize, + sort: { + field: 'date', + direction: 'desc' as const, + }, + titleField: 'title', + fields: [ 'date', 'service', 'amount' ], + layout: {}, + } ); + + const getActions = () => [ + { + id: 'view-receipt', + label: 'View receipt', + isPrimary: true, + icon: , + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + pageRedirect.redirect( getReceiptUrlFor( item.id ) ); + }, + }, + { + id: 'email-receipt', + label: 'Email receipt', + isPrimary: true, + icon: , + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + recordClickEvent( 'Email Receipt in Billing History' ); + sendBillingReceiptEmail( item.id ); + }, + }, + ]; + + return ( +
    +
    +
    - ); - }; - - recordClickEvent = ( eventAction: string ) => { - recordGoogleEvent( 'Me', eventAction ); - }; -} +
    + ); +}; function getIsSendingReceiptEmail( state: IAppState ) { return function isSendingBillingReceiptEmailForReceiptId( receiptId: number ) { From 60b957a69dac5b9eee18bd8735cbe1125543ad5e Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 20 Dec 2024 11:11:21 -0600 Subject: [PATCH 07/45] Prettier adjustment --- .../purchases/billing-history-data-view/billing-history-list.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index f0efc9ff36d02..f937dfa936986 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ import pageRedirect from '@automattic/calypso-router'; import { Gridicon } from '@automattic/components'; import { DataViews, Operator } from '@wordpress/dataviews'; From 893c7078147dafdd429afcbb8ceb9a659f1f2c48 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 30 Dec 2024 17:39:14 -0600 Subject: [PATCH 08/45] Adds filtering/sorting/searching --- .../billing-history-list.tsx | 216 +++++++++++++----- .../billing-history-data-view/style.scss | 152 ++++++++---- 2 files changed, 272 insertions(+), 96 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index f937dfa936986..fc5ad004d468e 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -2,7 +2,9 @@ import pageRedirect from '@automattic/calypso-router'; import { Gridicon } from '@automattic/components'; import { DataViews, Operator } from '@wordpress/dataviews'; import { localize, LocalizeProps } from 'i18n-calypso'; +import { isEqual } from 'lodash'; import moment from 'moment'; +import { useState } from 'react'; import { connect } from 'react-redux'; import { withLocalizedMoment } from 'calypso/components/localized-moment'; import { capitalPDangit } from 'calypso/lib/formatting'; @@ -12,12 +14,9 @@ import { BillingTransaction, BillingTransactionItem, } from 'calypso/state/billing-transactions/types'; -import { setPage } from 'calypso/state/billing-transactions/ui/actions'; -import getBillingTransactionFilters from 'calypso/state/selectors/get-billing-transaction-filters'; import getPastBillingTransactions from 'calypso/state/selectors/get-past-billing-transactions'; import isSendingBillingReceiptEmail from 'calypso/state/selectors/is-sending-billing-receipt-email'; import { IAppState } from 'calypso/state/types'; -import { filterTransactions, paginateTransactions } from './filter-transactions'; import { getTransactionTermLabel, groupDomainProducts, @@ -25,7 +24,7 @@ import { renderTransactionQuantitySummary, } from './utils'; -import '@wordpress/dataviews/build-style/style.css'; +import 'calypso/components/dataviews/style.scss'; import './style.scss'; const SERVICES = [ @@ -83,46 +82,154 @@ export interface BillingHistoryListProps { } export interface BillingHistoryListConnectedProps { - app?: string; - date: { newest: boolean }; - page: number; - pageSize: number; - query: string; - total: number; transactions: BillingTransaction[]; sendingBillingReceiptEmail: ( receiptId: string ) => boolean; moment: typeof moment; sendBillingReceiptEmail: ( receiptId: string ) => void; - setPage: ( transactionType: string, page: number ) => void; } type Props = BillingHistoryListProps & BillingHistoryListConnectedProps & LocalizeProps; const BillingHistoryListDataView: React.FC< Props > = ( { getReceiptUrlFor, - page, - pageSize, - total, transactions = [], sendBillingReceiptEmail, - setPage, translate, moment, } ) => { - const onChangeView = ( newView: { page?: number } ) => { - const newPage = typeof newView.page === 'number' ? newView.page : 1; - if ( newView.page !== page ) { - setPage( 'past', newPage ); + const [ view, setView ] = useState( { + type: 'table' as const, + search: '', + filters: [] as Array< { + field: string; + operator: Operator; + value: string | string[]; + } >, + page: 1, + perPage: 10, + sort: { + field: 'date', + direction: 'desc' as const, + }, + titleField: 'title', + fields: [ 'date', 'service', 'amount' ], + layout: {}, + } ); + + // Apply filtering + const filteredTransactions = transactions.filter( ( transaction ) => { + // Handle search + if ( view.search ) { + const searchTerm = view.search.toLowerCase(); + const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); + const searchableFields = [ + transaction.service, + transactionItem.product, + transactionItem.variation, + transactionItem.domain, + moment( transaction.date ).format( 'll' ), + transaction.amount, + ]; + + if ( + ! searchableFields.some( + ( field ) => field && field.toString().toLowerCase().includes( searchTerm ) + ) + ) { + return false; + } + } + + // Handle filters + if ( view.filters.length === 0 ) { + return true; } + + return view.filters.every( ( filter ) => { + if ( filter.field === 'service' && filter.value ) { + return transaction.service === filter.value; + } + return true; + } ); + } ); + + // Apply sorting + const sortedTransactions = [ ...filteredTransactions ].sort( ( a, b ) => { + let comparison = 0; + switch ( view.sort.field ) { + case 'date': + comparison = new Date( a.date ).getTime() - new Date( b.date ).getTime(); + break; + case 'service': { + const aService = a.items.length > 0 ? a.items[ 0 ].variation : a.service; + const bService = b.items.length > 0 ? b.items[ 0 ].variation : b.service; + comparison = ( aService || '' ).localeCompare( bService || '' ); + break; + } + case 'amount': + comparison = a.amount_integer - b.amount_integer; + break; + default: + return 0; + } + return view.sort.direction === 'desc' ? -comparison : comparison; + } ); + + const startIndex = ( view.page - 1 ) * view.perPage; + const paginatedTransactions = sortedTransactions.slice( startIndex, startIndex + view.perPage ); + + const onChangeView = ( newView: { + page?: number; + perPage?: number; + sort?: { + field: string; + direction: 'asc' | 'desc'; + }; + filters?: Array< { + field: string; + operator: Operator; + value: string | string[]; + } >; + search?: string; + } ) => { + setView( ( currentView ) => { + const updatedView = { ...currentView }; + + // Update only the changed properties + if ( newView.page !== undefined && newView.page !== currentView.page ) { + updatedView.page = newView.page; + } + + if ( newView.perPage && newView.perPage !== currentView.perPage ) { + updatedView.perPage = newView.perPage; + updatedView.page = 1; // Reset to first page + } + + if ( newView.sort && ! isEqual( newView.sort, currentView.sort ) ) { + updatedView.sort = newView.sort as typeof currentView.sort; + } + + if ( newView.filters && ! isEqual( newView.filters, currentView.filters ) ) { + updatedView.filters = newView.filters; + updatedView.page = 1; // Reset to first page + } + + if ( newView.search !== undefined ) { + updatedView.search = newView.search; + } + + return updatedView; + } ); }; const getFields = () => [ { id: 'date', label: 'Date', - type: 'datetime' as const, + type: 'text' as const, enableGlobalSearch: true, enableHiding: false, + enableSorting: true, getValue: ( { item }: { item: BillingTransaction } ) => { return item.date; }, @@ -132,19 +239,24 @@ const BillingHistoryListDataView: React.FC< Props > = ( { }, { id: 'service', - label: 'Summary', + label: 'App', type: 'text' as const, elements: SERVICES, enableGlobalSearch: true, enableHiding: false, + enableSorting: true, + filterBy: { + operators: [ 'is' as Operator ], + }, render: ( { item }: { item: BillingTransaction } ) => { return
    { serviceName( item, translate ) }
    ; }, getValue: ( { item }: { item: BillingTransaction } ) => { - return item.service; - }, - filterBy: { - operators: [ 'isAny', 'is', 'isAny' ] as Operator[], + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + if ( transactionItem.product === transactionItem.variation ) { + return transactionItem.product; + } + return capitalPDangit( transactionItem.variation ); }, }, { @@ -152,27 +264,16 @@ const BillingHistoryListDataView: React.FC< Props > = ( { label: 'Amount', type: 'text' as const, enableGlobalSearch: true, + enableSorting: true, + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.amount_integer; + }, render: ( { item }: { item: BillingTransaction } ) => { return ; }, }, ]; - const getView = () => ( { - type: 'table' as const, - search: '', - filters: [], - page, - perPage: pageSize, - sort: { - field: 'date', - direction: 'desc' as const, - }, - titleField: 'title', - fields: [ 'date', 'service', 'amount' ], - layout: {}, - } ); - const getActions = () => [ { id: 'view-receipt', @@ -201,13 +302,13 @@ const BillingHistoryListDataView: React.FC< Props > = ( {
    { + if ( ! siteId || ! transactions ) { + return transactions ?? []; + } + return transactions.filter( ( transaction ) => + transaction.items.some( ( item ) => String( item.site_id ) === String( siteId ) ) + ); +}; + export default connect( ( state: IAppState, { siteId }: BillingHistoryListProps ) => { const transactions = getPastBillingTransactions( state ); - const pageSize = 10; - const filteredTransactions = transactions && filterTransactions( transactions, {}, siteId ); - - const uiState = getBillingTransactionFilters( state, 'past' ); - const currentPage = uiState?.page ? uiState.page : 1; - - const paginatedTransactions = - filteredTransactions && paginateTransactions( filteredTransactions, currentPage, pageSize ); + const filteredBySite = filterTransactionsBySite( transactions, siteId ); return { - page: currentPage, - pageSize, - total: filteredTransactions?.length ?? 0, - transactions: paginatedTransactions, + transactions: filteredBySite, sendingBillingReceiptEmail: getIsSendingReceiptEmail( state ), }; }, { - setPage, sendBillingReceiptEmail: sendBillingReceiptEmailAction, } )( localize( withLocalizedMoment( BillingHistoryListDataView ) ) ); diff --git a/client/me/purchases/billing-history-data-view/style.scss b/client/me/purchases/billing-history-data-view/style.scss index 79c1adc6ae195..bb296f6f3f6dd 100644 --- a/client/me/purchases/billing-history-data-view/style.scss +++ b/client/me/purchases/billing-history-data-view/style.scss @@ -5,22 +5,18 @@ .dataviews-wrapper { width: 100%; + margin-bottom: 1rem; - .dataviews-view-table__actions-column { - .components-button.is-compact.has-icon:not(.dataviews-all-actions-button) { - .gridicon { - opacity: 0; - transition: opacity 0.15s ease-in-out; - } - } - } + .dataviews-view-table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; - .dataviews-view-table__row:hover { - background-color: rgba( 0, 0, 0, 0.05 ); - .dataviews-view-table__actions-column { - .components-button.is-compact.has-icon { + &__actions-column { + .components-button.is-compact.has-icon:not(.dataviews-all-actions-button) { .gridicon { - opacity: 1; + opacity: 0; + transition: opacity 0.15s ease-in-out; } } } @@ -30,59 +26,137 @@ background: var(--studio-white); opacity: 1; + &:hover { + background-color: var(--color-neutral-0); + .dataviews-view-table__actions-column { + .components-button.is-compact.has-icon { + .gridicon { + opacity: 1; + } + } + } + } + th { - border-bottom: 1px solid var(--color-neutral-5); - font-size: 0.8125rem; - font-weight: 400; + border-bottom: 1px solid var(--color-border-subtle); + font-size: 0.875rem; + font-weight: 600; vertical-align: middle; padding: 12px; text-align: left; text-transform: uppercase; + + button { + padding: 0; + text-transform: uppercase; + } } td { - border-bottom: 1px solid var(--color-neutral-5); + border-bottom: 1px solid var(--color-border-subtle); vertical-align: middle; padding: 12px; text-align: left; + + strong { + display: block; + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.25rem; + } + + small { + display: block; + font-size: 0.875rem; + color: var(--color-text-subtle); + margin-top: 0.25rem; + } } + + .dataviews-view-table__actions-column { + text-align: right; + } } - .dataviews-view-table__cell-content-wrapper { - font-size: 0.8125rem; + .dataviews-view-table__cell { + font-size: 0.875rem; + + &--align-right { + text-align: right; + } + + &--align-center { + text-align: center; + } + + &--actions { + .dataviews-view-table__cell-content { + justify-content: flex-end; + } + } } - small { - display: block; + .dataviews-view-table__cell-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + + strong { + display: block; + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.25rem; + } + + small { + display: block; + font-size: 0.875rem; + color: var(--color-text-subtle); + } } - .dataviews-filters__container-visibility-toggle { - position: relative; + .dataviews-view-table__cell-actions { + display: flex; + gap: 0.5rem; } - .dataviews-filters-toggle__count { - position: absolute; - top: 0; - right: 0; - transform: translate(50%, -50%); - background: var(--wp-admin-theme-color, #3858e9); - height: 16px; - min-width: 16px; - line-height: 16px; - padding: 0 4px; - text-align: center; - border-radius: 8px; - font-size: 11px; - outline: var(--wp-admin-border-width-focus) solid #fff; - color: #fff; - box-sizing: border-box; + .dataviews-view-table__cell-action { + border-radius: 2px; + font-size: 0.875rem; + padding: 0.25rem 0.5rem; + color: var(--color-text-inverted); + background-color: var(--color-primary); + border: none; + cursor: pointer; + + &:hover { + background-color: var(--color-primary-60); + } + + &--secondary { + font-size: 0.875rem; + background-color: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + + &:hover { + background-color: var(--color-neutral-0); + } + } } } .dataviews__view-actions { margin-top: 1em; + padding-left: 0; + padding-right: 0; } + .dataviews-filters__container { + padding: 0; + } + .section-nav { box-shadow: none; } From bcbb7371b8bd04f81eb83e9a7dc9f3090d258de8 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Wed, 1 Jan 2025 15:54:10 -0600 Subject: [PATCH 09/45] Fixes filtered pagination totals --- .../billing-history-data-view/billing-history-list.tsx | 6 ++---- client/me/purchases/billing-history-data-view/style.scss | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index fc5ad004d468e..fb172206dbbda 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -111,9 +111,7 @@ const BillingHistoryListDataView: React.FC< Props > = ( { field: 'date', direction: 'desc' as const, }, - titleField: 'title', fields: [ 'date', 'service', 'amount' ], - layout: {}, } ); // Apply filtering @@ -304,8 +302,8 @@ const BillingHistoryListDataView: React.FC< Props > = ( { Date: Thu, 2 Jan 2025 10:09:59 -0600 Subject: [PATCH 10/45] Removes unused files and adds filter for purchase type --- .../billing-history-filters.jsx | 228 ------------------ .../billing-history-list.tsx | 29 +++ .../filter-transactions.ts | 91 ------- .../billing-history-data-view/style.scss | 6 + 4 files changed, 35 insertions(+), 319 deletions(-) delete mode 100644 client/me/purchases/billing-history-data-view/billing-history-filters.jsx delete mode 100644 client/me/purchases/billing-history-data-view/filter-transactions.ts diff --git a/client/me/purchases/billing-history-data-view/billing-history-filters.jsx b/client/me/purchases/billing-history-data-view/billing-history-filters.jsx deleted file mode 100644 index 3560c7da88eb1..0000000000000 --- a/client/me/purchases/billing-history-data-view/billing-history-filters.jsx +++ /dev/null @@ -1,228 +0,0 @@ -import { SelectDropdown } from '@automattic/components'; -import closest from 'component-closest'; -import { localize } from 'i18n-calypso'; -import { find, isEqual } from 'lodash'; -import PropTypes from 'prop-types'; -import { Component } from 'react'; -import { connect } from 'react-redux'; -import { withLocalizedMoment } from 'calypso/components/localized-moment'; -import { recordGoogleEvent } from 'calypso/state/analytics/actions'; -import { setApp, setDate } from 'calypso/state/billing-transactions/ui/actions'; -import getBillingTransactionAppFilterValues from 'calypso/state/selectors/get-billing-transaction-app-filter-values'; -import getBillingTransactionDateFilterValues from 'calypso/state/selectors/get-billing-transaction-date-filter-values'; -import getBillingTransactionFilters from 'calypso/state/selectors/get-billing-transaction-filters'; - -class BillingHistoryFilters extends Component { - state = { - activePopover: '', - }; - - preventEnterKeySubmission = ( event ) => { - event.preventDefault(); - }; - - componentDidMount() { - document.body.addEventListener( 'click', this.closePopoverIfClickedOutside ); - } - - componentWillUnmount() { - document.body.removeEventListener( 'click', this.closePopoverIfClickedOutside ); - } - - recordClickEvent = ( action ) => { - this.props.recordGoogleEvent( 'Me', 'Clicked on ' + action ); - }; - - getDatePopoverItemClickHandler( analyticsEvent, date ) { - return () => { - const { transactionType } = this.props; - this.recordClickEvent( 'Date Popover Item: ' + analyticsEvent ); - this.props.setDate( transactionType, date.month, date.operator ); - this.setState( { activePopover: '' } ); - }; - } - - getAppPopoverItemClickHandler( analyticsEvent, app ) { - return () => { - this.recordClickEvent( 'App Popover Item: ' + analyticsEvent ); - this.props.setApp( this.props.transactionType, app ); - this.setState( { activePopover: '' } ); - }; - } - - handleDatePopoverLinkClick = () => { - this.recordClickEvent( 'Toggle Date Popover in Billing History' ); - this.togglePopover( 'date' ); - }; - - handleAppsPopoverLinkClick = () => { - this.recordClickEvent( 'Toggle Apps Popover in Billing History' ); - this.togglePopover( 'apps' ); - }; - - closePopoverIfClickedOutside = ( event ) => { - if ( closest( event.target, 'thead' ) ) { - return; - } - - this.setState( { activePopover: '' } ); - }; - - render() { - return ( - - - - { this.renderDatePopover() } - { this.renderAppsPopover() } - - - - ); - } - - getFilterTitle( filter ) { - if ( ! filter ) { - return this.props.translate( 'Date' ); - } - - if ( filter.older ) { - return this.props.translate( 'Older' ); - } - - return this.props.moment( filter.dateString ).format( 'MMM YYYY' ); - } - - renderDatePopover() { - const { dateFilters, filter, translate } = this.props; - const selectedFilter = find( dateFilters, { value: filter.date } ); - const selectedText = this.getFilterTitle( selectedFilter ); - - return ( - - { translate( 'Recent Transactions' ) } - { this.renderDatePicker( - 'Newest', - translate( 'Newest' ), - { - month: null, - operator: null, - }, - null - ) } - - { translate( 'By Month' ) } - { dateFilters.map( ( dateFilter, index ) => { - let analyticsEvent = 'Current Month'; - - if ( 1 === index ) { - analyticsEvent = '1 Month Before'; - } else if ( 1 < index ) { - analyticsEvent = index + ' Months Before'; - } - - return this.renderDatePicker( - index, - this.getFilterTitle( dateFilter ), - dateFilter.value, - dateFilter.count, - analyticsEvent - ); - } ) } - - ); - } - - togglePopover( name ) { - let activePopover; - if ( this.state.activePopover === name ) { - activePopover = ''; - } else { - activePopover = name; - } - - this.setState( { activePopover: activePopover } ); - } - - renderDatePicker( titleKey, titleTranslated, value, count, analyticsEvent ) { - const currentDate = this.props.filter.date; - const isSelected = isEqual( currentDate, value ); - analyticsEvent = 'undefined' === typeof analyticsEvent ? titleKey : analyticsEvent; - - return ( - - { titleTranslated } - - ); - } - - renderAppsPopover() { - const { appFilters, filter, translate } = this.props; - const selectedFilter = find( appFilters, { value: filter.app } ); - const selectedText = selectedFilter ? selectedFilter.title : translate( 'All apps' ); - - return ( - - { translate( 'App name' ) } - { this.renderAppPicker( translate( 'All apps' ), 'all' ) } - { appFilters.map( function ( { title, value, count } ) { - return this.renderAppPicker( title, value, count, 'Specific App' ); - }, this ) } - - ); - } - - renderAppPicker( title, app, count, analyticsEvent ) { - const selected = app === this.props.filter.app; - - return ( - - { title } - - ); - } -} - -BillingHistoryFilters.propTypes = { - //connected props - appFilters: PropTypes.array.isRequired, - dateFilters: PropTypes.array.isRequired, - filter: PropTypes.object.isRequired, - //own props - transactionType: PropTypes.string.isRequired, - siteId: PropTypes.number, -}; - -export default connect( - ( state, { transactionType, siteId } ) => ( { - appFilters: getBillingTransactionAppFilterValues( state, transactionType, siteId ), - dateFilters: getBillingTransactionDateFilterValues( state, transactionType, siteId ), - filter: getBillingTransactionFilters( state, transactionType ), - } ), - { - recordGoogleEvent, - setApp, - setDate, - } -)( localize( withLocalizedMoment( BillingHistoryFilters ) ) ); diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index fb172206dbbda..a0e9c4f05cc0f 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -147,6 +147,10 @@ const BillingHistoryListDataView: React.FC< Props > = ( { if ( filter.field === 'service' && filter.value ) { return transaction.service === filter.value; } + if ( filter.field === 'type' && filter.value ) { + const [ firstItem ] = groupDomainProducts( transaction.items, translate ); + return firstItem.type === filter.value; + } return true; } ); } ); @@ -257,6 +261,31 @@ const BillingHistoryListDataView: React.FC< Props > = ( { return capitalPDangit( transactionItem.variation ); }, }, + { + id: 'type', + label: 'Type', + type: 'text' as const, + elements: [ + { value: 'new purchase', label: 'New Purchase' }, + { value: 'recurring', label: 'Renewal' }, + ], + enableGlobalSearch: true, + enableHiding: false, + enableSorting: true, + filterBy: { + operators: [ 'is' as Operator ], + }, + render: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return ( +
    { transactionItem.type_localized || capitalPDangit( transactionItem.type ) }
    + ); + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return transactionItem.type; + }, + }, { id: 'amount', label: 'Amount', diff --git a/client/me/purchases/billing-history-data-view/filter-transactions.ts b/client/me/purchases/billing-history-data-view/filter-transactions.ts deleted file mode 100644 index 4ca8bebf8e61a..0000000000000 --- a/client/me/purchases/billing-history-data-view/filter-transactions.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { isValueTruthy } from '@automattic/wpcom-checkout'; -import { getLocaleSlug } from 'i18n-calypso'; -import moment from 'moment'; -import { - BillingTransaction, - BillingTransactionUiState, -} from 'calypso/state/billing-transactions/types'; - -/** - * Utility function for formatting date for text search - */ -function formatDate( date: string ): string { - const localeSlug = getLocaleSlug(); - return moment( date ) - .locale( localeSlug ?? '' ) - .format( 'll' ); -} - -/** - * Utility function extracting searchable strings from a single transaction - */ -function getSearchableStrings( transaction: BillingTransaction ): string[] { - const rootStrings: string[] = Object.values( transaction ).filter( - ( value ) => typeof value === 'string' - ); - const dateString: string | null = transaction.date ? formatDate( transaction.date ) : null; - const itemStrings: string[] = transaction.items.flatMap( ( item ) => Object.values( item ) ); - - return [ ...rootStrings, dateString, ...itemStrings ].filter( isValueTruthy ); -} - -/** - * Utility function to search the transactions by the provided searchQuery - */ -function searchTransactions( - transactions: BillingTransaction[], - searchQuery: string -): BillingTransaction[] { - const needle = searchQuery.toLowerCase(); - - return transactions.filter( ( transaction: BillingTransaction ) => - getSearchableStrings( transaction ).some( ( val ) => { - const haystack = val.toString().toLowerCase(); - return haystack.includes( needle ); - } ) - ); -} - -export function filterTransactions( - transactions: BillingTransaction[] | null | undefined, - filter: BillingTransactionUiState, - siteId: number | string | null | undefined -): BillingTransaction[] { - const { app, date, query } = filter; - let results = query ? searchTransactions( transactions ?? [], query ) : transactions ?? []; - - if ( date && date.month && date.operator ) { - results = results.filter( ( transaction ) => { - const transactionDate = moment( transaction.date ); - - if ( 'equal' === date.operator ) { - return transactionDate.isSame( date.month, 'month' ); - } else if ( 'before' === date.operator ) { - return transactionDate.isBefore( date.month, 'month' ); - } - } ); - } - - if ( app && app !== 'all' ) { - results = results.filter( ( transaction ) => transaction.service === app ); - } - - if ( siteId ) { - results = results.filter( ( transaction ) => { - return transaction.items.some( ( receiptItem ) => { - return String( receiptItem.site_id ) === String( siteId ); - } ); - } ); - } - - return results; -} - -export function paginateTransactions( - transactions: BillingTransaction[], - page: number | null | undefined, - pageSize: number -): BillingTransaction[] { - const pageIndex = ( page ?? 1 ) - 1; - return transactions.slice( pageIndex * pageSize, pageIndex * pageSize + pageSize ); -} diff --git a/client/me/purchases/billing-history-data-view/style.scss b/client/me/purchases/billing-history-data-view/style.scss index 25ad396017aa2..3597f4724befe 100644 --- a/client/me/purchases/billing-history-data-view/style.scss +++ b/client/me/purchases/billing-history-data-view/style.scss @@ -211,3 +211,9 @@ body.is-section-me div.layout.is-global-sidebar-visible .layout__primary .billing-history.main > * { max-width: 100%; } + +@media screen and (min-width: 782px) { + body.is-section-me .navigation-header::after { + display: none !important; + } +} \ No newline at end of file From ea123e19cd71d1ccf4f4ea6bfadd27c41ae71dc5 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 2 Jan 2025 10:56:15 -0600 Subject: [PATCH 11/45] Removes unused legacy filter function --- .../billing-history-list.tsx | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index a0e9c4f05cc0f..b79bbad8efe32 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -77,7 +77,6 @@ const serviceName = ( export interface BillingHistoryListProps { header?: boolean; - siteId?: string | number | null; getReceiptUrlFor: ( receiptId: string ) => string; } @@ -115,7 +114,7 @@ const BillingHistoryListDataView: React.FC< Props > = ( { } ); // Apply filtering - const filteredTransactions = transactions.filter( ( transaction ) => { + const filteredTransactions = ( transactions ?? [] ).filter( ( transaction ) => { // Handle search if ( view.search ) { const searchTerm = view.search.toLowerCase(); @@ -354,28 +353,11 @@ function getIsSendingReceiptEmail( state: IAppState ) { }; } -const filterTransactionsBySite = ( - transactions: BillingTransaction[] | null | undefined, - siteId: string | number | null | undefined -): BillingTransaction[] => { - if ( ! siteId || ! transactions ) { - return transactions ?? []; - } - return transactions.filter( ( transaction ) => - transaction.items.some( ( item ) => String( item.site_id ) === String( siteId ) ) - ); -}; - export default connect( - ( state: IAppState, { siteId }: BillingHistoryListProps ) => { - const transactions = getPastBillingTransactions( state ); - const filteredBySite = filterTransactionsBySite( transactions, siteId ); - - return { - transactions: filteredBySite, - sendingBillingReceiptEmail: getIsSendingReceiptEmail( state ), - }; - }, + ( state: IAppState ) => ( { + transactions: getPastBillingTransactions( state ), + sendingBillingReceiptEmail: getIsSendingReceiptEmail( state ), + } ), { sendBillingReceiptEmail: sendBillingReceiptEmailAction, } From 14eae29d25cb9e8f5eded10f0d3a2a02c4bff58d Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 2 Jan 2025 11:55:45 -0600 Subject: [PATCH 12/45] Adds date filter and dynamic service filter --- .../billing-history-list.tsx | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index b79bbad8efe32..ba770b698c751 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -27,16 +27,39 @@ import { import 'calypso/components/dataviews/style.scss'; import './style.scss'; -const SERVICES = [ - { value: 'WordPress.com', label: 'WordPress.com' }, - { value: 'Jetpack', label: 'Jetpack' }, - { value: 'WooCommerce', label: 'WooCommerce' }, -]; - const recordClickEvent = ( eventAction: string ) => { recordGoogleEvent( 'Me', eventAction ); }; +const getUniqueMonths = ( + transactions: BillingTransaction[] +): Array< { value: string; label: string } > => { + const uniqueMonths = new Set( + transactions.map( ( transaction ) => moment( transaction.date ).format( 'YYYY-MM' ) ) + ); + + return Array.from( uniqueMonths ) + .sort() + .reverse() + .map( ( monthStr ) => ( { + value: monthStr, + label: moment( monthStr ).format( 'MMMM YYYY' ), + } ) ); +}; + +const getUniqueServices = ( + transactions: BillingTransaction[] +): Array< { value: string; label: string } > => { + const uniqueServices = new Set( transactions.map( ( transaction ) => transaction.service ) ); + + return Array.from( uniqueServices ) + .sort() + .map( ( service ) => ( { + value: service, + label: service, + } ) ); +}; + const serviceNameDescription = ( transaction: BillingTransactionItem, translate: LocalizeProps[ 'translate' ] @@ -110,7 +133,7 @@ const BillingHistoryListDataView: React.FC< Props > = ( { field: 'date', direction: 'desc' as const, }, - fields: [ 'date', 'service', 'amount' ], + fields: [ 'date', 'service', 'type', 'amount' ], } ); // Apply filtering @@ -150,6 +173,9 @@ const BillingHistoryListDataView: React.FC< Props > = ( { const [ firstItem ] = groupDomainProducts( transaction.items, translate ); return firstItem.type === filter.value; } + if ( filter.field === 'date' && filter.value ) { + return moment( transaction.date ).format( 'YYYY-MM' ) === filter.value; + } return true; } ); } ); @@ -228,11 +254,15 @@ const BillingHistoryListDataView: React.FC< Props > = ( { id: 'date', label: 'Date', type: 'text' as const, + elements: getUniqueMonths( transactions ?? [] ), enableGlobalSearch: true, enableHiding: false, enableSorting: true, + filterBy: { + operators: [ 'is' as Operator ], + }, getValue: ( { item }: { item: BillingTransaction } ) => { - return item.date; + return moment( item.date ).format( 'YYYY-MM' ); }, render: ( { item }: { item: BillingTransaction } ) => { return ; @@ -242,7 +272,7 @@ const BillingHistoryListDataView: React.FC< Props > = ( { id: 'service', label: 'App', type: 'text' as const, - elements: SERVICES, + elements: getUniqueServices( transactions ?? [] ), enableGlobalSearch: true, enableHiding: false, enableSorting: true, @@ -347,11 +377,9 @@ const BillingHistoryListDataView: React.FC< Props > = ( { ); }; -function getIsSendingReceiptEmail( state: IAppState ) { - return function isSendingBillingReceiptEmailForReceiptId( receiptId: number ) { - return isSendingBillingReceiptEmail( state, receiptId ); - }; -} +const getIsSendingReceiptEmail = ( state: IAppState ) => { + return ( receiptId: number ) => isSendingBillingReceiptEmail( state, receiptId ); +}; export default connect( ( state: IAppState ) => ( { From 92e17418db08801930710206e704a9a57e3f9e17 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 2 Jan 2025 16:28:50 -0600 Subject: [PATCH 13/45] Refactoring and column swapping/visibility added --- .../billing-history-list.tsx | 413 ++++++++++-------- 1 file changed, 238 insertions(+), 175 deletions(-) diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history-data-view/billing-history-list.tsx index ba770b698c751..ba0cd62e06485 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history-data-view/billing-history-list.tsx @@ -1,32 +1,97 @@ +/* eslint-disable prettier/prettier */ import pageRedirect from '@automattic/calypso-router'; import { Gridicon } from '@automattic/components'; import { DataViews, Operator } from '@wordpress/dataviews'; -import { localize, LocalizeProps } from 'i18n-calypso'; +import { useTranslate } from 'i18n-calypso'; import { isEqual } from 'lodash'; import moment from 'moment'; -import { useState } from 'react'; -import { connect } from 'react-redux'; +import { useState, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { withLocalizedMoment } from 'calypso/components/localized-moment'; import { capitalPDangit } from 'calypso/lib/formatting'; import { recordGoogleEvent } from 'calypso/state/analytics/actions'; -import { sendBillingReceiptEmail as sendBillingReceiptEmailAction } from 'calypso/state/billing-transactions/actions'; +import { sendBillingReceiptEmail } from 'calypso/state/billing-transactions/actions'; import { BillingTransaction, BillingTransactionItem, } from 'calypso/state/billing-transactions/types'; import getPastBillingTransactions from 'calypso/state/selectors/get-past-billing-transactions'; -import isSendingBillingReceiptEmail from 'calypso/state/selectors/is-sending-billing-receipt-email'; -import { IAppState } from 'calypso/state/types'; import { getTransactionTermLabel, groupDomainProducts, TransactionAmount, renderTransactionQuantitySummary, } from './utils'; +import type { IAppState } from 'calypso/state/types'; +import type { Action } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import 'calypso/components/dataviews/style.scss'; import './style.scss'; +const INITIAL_PAGE = 1; +const ITEMS_PER_PAGE = 10; + +type SortableField = 'date' | 'service' | 'type' | 'amount'; + +const SORT_DEFAULTS = { + FIELD: 'date' as SortableField, + DIRECTION: 'desc', +} as const; + +const TABLE_DEFAULTS = { + TYPE: 'table', + FIELDS: [ 'date', 'service', 'type', 'amount' ] as string[], +} as const; + +type ViewType = 'table'; +type SortDirection = 'asc' | 'desc'; + +interface ViewStateUpdate { + page?: number; + perPage?: number; + sort?: { + field: string; + direction: SortDirection; + }; + filters?: Array< { + field: string; + operator: Operator; + value: string | string[]; + } >; + search?: string; + fields?: string[]; +} + +interface ViewState { + type: ViewType; + search: string; + filters: Array< { + field: string; + operator: Operator; + value: string | string[]; + } >; + page: number; + perPage: number; + sort: { + field: SortableField; + direction: SortDirection; + }; + fields: string[]; + hiddenFields: string[]; +} + +const DATE_FORMATS = { + MONTH_YEAR: 'YYYY-MM', + MONTH_YEAR_LABEL: 'MMMM YYYY', + DISPLAY: 'll', +} as const; + +const TRANSACTION_TYPES = { + NEW_PURCHASE: { value: 'new purchase', label: 'New Purchase' }, + RENEWAL: { value: 'recurring', label: 'Renewal' }, +} as const; + const recordClickEvent = ( eventAction: string ) => { recordGoogleEvent( 'Me', eventAction ); }; @@ -35,7 +100,9 @@ const getUniqueMonths = ( transactions: BillingTransaction[] ): Array< { value: string; label: string } > => { const uniqueMonths = new Set( - transactions.map( ( transaction ) => moment( transaction.date ).format( 'YYYY-MM' ) ) + transactions.map( ( transaction ) => + moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) + ) ); return Array.from( uniqueMonths ) @@ -43,7 +110,7 @@ const getUniqueMonths = ( .reverse() .map( ( monthStr ) => ( { value: monthStr, - label: moment( monthStr ).format( 'MMMM YYYY' ), + label: moment( monthStr ).format( DATE_FORMATS.MONTH_YEAR_LABEL ), } ) ); }; @@ -62,7 +129,7 @@ const getUniqueServices = ( const serviceNameDescription = ( transaction: BillingTransactionItem, - translate: LocalizeProps[ 'translate' ] + translate: ReturnType< typeof useTranslate > ) => { const plan = capitalPDangit( transaction.variation ); const termLabel = getTransactionTermLabel( transaction, translate ); @@ -80,7 +147,7 @@ const serviceNameDescription = ( const serviceName = ( transaction: BillingTransaction, - translate: LocalizeProps[ 'translate' ] + translate: ReturnType< typeof useTranslate > ) => { const [ transactionItem, ...moreTransactionItems ] = groupDomainProducts( transaction.items, @@ -99,46 +166,47 @@ const serviceName = ( }; export interface BillingHistoryListProps { - header?: boolean; getReceiptUrlFor: ( receiptId: string ) => string; } -export interface BillingHistoryListConnectedProps { - transactions: BillingTransaction[]; - sendingBillingReceiptEmail: ( receiptId: string ) => boolean; +interface WithMoment { moment: typeof moment; - sendBillingReceiptEmail: ( receiptId: string ) => void; } -type Props = BillingHistoryListProps & BillingHistoryListConnectedProps & LocalizeProps; +const usePagination = ( items: BillingTransaction[], page: number, perPage: number ) => { + return useMemo( () => { + const startIndex = ( page - 1 ) * perPage; + return { + paginatedItems: items.slice( startIndex, startIndex + perPage ), + totalPages: Math.ceil( items.length / perPage ), + totalItems: items.length, + }; + }, [ items, page, perPage ] ); +}; -const BillingHistoryListDataView: React.FC< Props > = ( { +const BillingHistoryListDataView: React.FC< BillingHistoryListProps & WithMoment > = ( { getReceiptUrlFor, - transactions = [], - sendBillingReceiptEmail, - translate, moment, } ) => { - const [ view, setView ] = useState( { - type: 'table' as const, + const translate = useTranslate(); + const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); + const transactions = useSelector( getPastBillingTransactions ); + + const [ view, setView ] = useState< ViewState >( { + type: TABLE_DEFAULTS.TYPE, search: '', - filters: [] as Array< { - field: string; - operator: Operator; - value: string | string[]; - } >, - page: 1, - perPage: 10, + filters: [], + page: INITIAL_PAGE, + perPage: ITEMS_PER_PAGE, sort: { - field: 'date', - direction: 'desc' as const, + field: SORT_DEFAULTS.FIELD, + direction: SORT_DEFAULTS.DIRECTION, }, - fields: [ 'date', 'service', 'type', 'amount' ], + fields: [ ...TABLE_DEFAULTS.FIELDS ], + hiddenFields: [], } ); - // Apply filtering const filteredTransactions = ( transactions ?? [] ).filter( ( transaction ) => { - // Handle search if ( view.search ) { const searchTerm = view.search.toLowerCase(); const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); @@ -147,7 +215,7 @@ const BillingHistoryListDataView: React.FC< Props > = ( { transactionItem.product, transactionItem.variation, transactionItem.domain, - moment( transaction.date ).format( 'll' ), + moment( transaction.date ).format( DATE_FORMATS.DISPLAY ), transaction.amount, ]; @@ -160,7 +228,6 @@ const BillingHistoryListDataView: React.FC< Props > = ( { } } - // Handle filters if ( view.filters.length === 0 ) { return true; } @@ -174,16 +241,17 @@ const BillingHistoryListDataView: React.FC< Props > = ( { return firstItem.type === filter.value; } if ( filter.field === 'date' && filter.value ) { - return moment( transaction.date ).format( 'YYYY-MM' ) === filter.value; + return moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) === filter.value; } return true; } ); } ); - // Apply sorting const sortedTransactions = [ ...filteredTransactions ].sort( ( a, b ) => { let comparison = 0; - switch ( view.sort.field ) { + const sortField = view.sort.field as SortableField; + + switch ( sortField ) { case 'date': comparison = new Date( a.date ).getTime() - new Date( b.date ).getTime(); break; @@ -202,174 +270,181 @@ const BillingHistoryListDataView: React.FC< Props > = ( { return view.sort.direction === 'desc' ? -comparison : comparison; } ); - const startIndex = ( view.page - 1 ) * view.perPage; - const paginatedTransactions = sortedTransactions.slice( startIndex, startIndex + view.perPage ); + const { paginatedItems, totalPages, totalItems } = usePagination( + sortedTransactions, + view.page, + view.perPage + ); - const onChangeView = ( newView: { - page?: number; - perPage?: number; - sort?: { - field: string; - direction: 'asc' | 'desc'; - }; - filters?: Array< { - field: string; - operator: Operator; - value: string | string[]; - } >; - search?: string; - } ) => { + const onChangeView = ( newView: ViewStateUpdate ) => { setView( ( currentView ) => { const updatedView = { ...currentView }; - // Update only the changed properties if ( newView.page !== undefined && newView.page !== currentView.page ) { updatedView.page = newView.page; } if ( newView.perPage && newView.perPage !== currentView.perPage ) { updatedView.perPage = newView.perPage; - updatedView.page = 1; // Reset to first page + updatedView.page = 1; } if ( newView.sort && ! isEqual( newView.sort, currentView.sort ) ) { - updatedView.sort = newView.sort as typeof currentView.sort; + updatedView.sort = { + field: newView.sort.field as SortableField, + direction: newView.sort.direction, + }; } if ( newView.filters && ! isEqual( newView.filters, currentView.filters ) ) { updatedView.filters = newView.filters; - updatedView.page = 1; // Reset to first page + updatedView.page = 1; } if ( newView.search !== undefined ) { updatedView.search = newView.search; } + if ( newView.fields !== undefined ) { + updatedView.fields = newView.fields; + } + return updatedView; } ); }; - const getFields = () => [ - { - id: 'date', - label: 'Date', - type: 'text' as const, - elements: getUniqueMonths( transactions ?? [] ), - enableGlobalSearch: true, - enableHiding: false, - enableSorting: true, - filterBy: { - operators: [ 'is' as Operator ], - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - return moment( item.date ).format( 'YYYY-MM' ); - }, - render: ( { item }: { item: BillingTransaction } ) => { - return ; - }, - }, - { - id: 'service', - label: 'App', - type: 'text' as const, - elements: getUniqueServices( transactions ?? [] ), - enableGlobalSearch: true, - enableHiding: false, - enableSorting: true, - filterBy: { - operators: [ 'is' as Operator ], - }, - render: ( { item }: { item: BillingTransaction } ) => { - return
    { serviceName( item, translate ) }
    ; - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - if ( transactionItem.product === transactionItem.variation ) { - return transactionItem.product; - } - return capitalPDangit( transactionItem.variation ); - }, - }, - { - id: 'type', - label: 'Type', - type: 'text' as const, - elements: [ - { value: 'new purchase', label: 'New Purchase' }, - { value: 'recurring', label: 'Renewal' }, - ], - enableGlobalSearch: true, - enableHiding: false, - enableSorting: true, - filterBy: { - operators: [ 'is' as Operator ], - }, - render: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - return ( -
    { transactionItem.type_localized || capitalPDangit( transactionItem.type ) }
    - ); + const fields = useMemo( () => { + const fieldDefinitions = { + date: { + id: 'date', + label: 'Date', + type: 'text' as const, + elements: getUniqueMonths( transactions ?? [] ), + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: view.hiddenFields.includes( 'date' ), + filterBy: { + operators: [ 'is' as Operator ], + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + return moment( item.date ).format( DATE_FORMATS.MONTH_YEAR ); + }, + render: ( { item }: { item: BillingTransaction } ) => { + return ; + }, }, - getValue: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - return transactionItem.type; + service: { + id: 'service', + label: 'App', + type: 'text' as const, + elements: getUniqueServices( transactions ?? [] ), + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: view.hiddenFields.includes( 'service' ), + filterBy: { + operators: [ 'is' as Operator ], + }, + render: ( { item }: { item: BillingTransaction } ) => { + return
    { serviceName( item, translate ) }
    ; + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + if ( transactionItem.product === transactionItem.variation ) { + return transactionItem.product; + } + return capitalPDangit( transactionItem.variation ); + }, }, - }, - { - id: 'amount', - label: 'Amount', - type: 'text' as const, - enableGlobalSearch: true, - enableSorting: true, - getValue: ( { item }: { item: BillingTransaction } ) => { - return item.amount_integer; + type: { + id: 'type', + label: 'Type', + type: 'text' as const, + elements: [ TRANSACTION_TYPES.NEW_PURCHASE, TRANSACTION_TYPES.RENEWAL ], + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: view.hiddenFields.includes( 'type' ), + filterBy: { + operators: [ 'is' as Operator ], + }, + render: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return ( +
    { transactionItem.type_localized || capitalPDangit( transactionItem.type ) }
    + ); + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return transactionItem.type; + }, }, - render: ( { item }: { item: BillingTransaction } ) => { - return ; + amount: { + id: 'amount', + label: 'Amount', + type: 'text' as const, + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: view.hiddenFields.includes( 'amount' ), + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.amount_integer; + }, + render: ( { item }: { item: BillingTransaction } ) => { + return ; + }, }, - }, - ]; - - const getActions = () => [ - { - id: 'view-receipt', - label: 'View receipt', - isPrimary: true, - icon: , - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - pageRedirect.redirect( getReceiptUrlFor( item.id ) ); + }; + + return view.fields.map( + ( fieldId ) => fieldDefinitions[ fieldId as keyof typeof fieldDefinitions ] + ); + }, [ transactions, view.hiddenFields, view.fields, translate, moment ] ); + + const actions = useMemo( + () => [ + { + id: 'view-receipt', + label: 'View receipt', + isPrimary: true, + icon: , + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + pageRedirect.redirect( getReceiptUrlFor( item.id ) ); + }, }, - }, - { - id: 'email-receipt', - label: 'Email receipt', - isPrimary: true, - icon: , - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - recordClickEvent( 'Email Receipt in Billing History' ); - sendBillingReceiptEmail( item.id ); + { + id: 'email-receipt', + label: 'Email receipt', + isPrimary: true, + icon: , + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + recordClickEvent( 'Email Receipt in Billing History' ); + dispatch( sendBillingReceiptEmail( item.id ) ); + }, }, - }, - ]; + ], + [ dispatch, getReceiptUrlFor ] + ); return (
    @@ -377,16 +452,4 @@ const BillingHistoryListDataView: React.FC< Props > = ( { ); }; -const getIsSendingReceiptEmail = ( state: IAppState ) => { - return ( receiptId: number ) => isSendingBillingReceiptEmail( state, receiptId ); -}; - -export default connect( - ( state: IAppState ) => ( { - transactions: getPastBillingTransactions( state ), - sendingBillingReceiptEmail: getIsSendingReceiptEmail( state ), - } ), - { - sendBillingReceiptEmail: sendBillingReceiptEmailAction, - } -)( localize( withLocalizedMoment( BillingHistoryListDataView ) ) ); +export default withLocalizedMoment( BillingHistoryListDataView ); From 6ace1e421497728ccd6722eb61a1bba1c02a8644 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 3 Jan 2025 07:48:58 -0600 Subject: [PATCH 14/45] Removes unnecessary params --- client/me/purchases/billing-history/main.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/me/purchases/billing-history/main.tsx b/client/me/purchases/billing-history/main.tsx index ec105ff2de638..6c4d853936b74 100644 --- a/client/me/purchases/billing-history/main.tsx +++ b/client/me/purchases/billing-history/main.tsx @@ -80,7 +80,7 @@ function BillingHistory() { } ) } /> - + From 129a7cf43a72f38cfc6df589af599e20b4f76080 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 3 Jan 2025 08:22:57 -0600 Subject: [PATCH 15/45] Moves DataView list back to billing-history folder --- .../billing-history-data-view/README.md | 10 - .../billing-history-data-view/controller.js | 17 - .../billing-history-data-view/main.tsx | 83 -- .../billing-history-data-view/receipt.tsx | 728 ------------------ .../test/filter-transactions.ts | 403 ---------- .../test/paginate-transactions.ts | 287 ------- .../billing-history-data-view/test/tax.tsx | 272 ------- .../billing-history-data-view/test/utils.ts | 354 --------- .../billing-history-data-view/utils.tsx | 344 --------- .../vat-vendor-details.tsx | 35 - .../billing-history-list-data-view.tsx} | 2 +- client/me/purchases/billing-history/main.tsx | 2 +- .../style-data-view.scss} | 0 13 files changed, 2 insertions(+), 2535 deletions(-) delete mode 100644 client/me/purchases/billing-history-data-view/README.md delete mode 100644 client/me/purchases/billing-history-data-view/controller.js delete mode 100644 client/me/purchases/billing-history-data-view/main.tsx delete mode 100644 client/me/purchases/billing-history-data-view/receipt.tsx delete mode 100644 client/me/purchases/billing-history-data-view/test/filter-transactions.ts delete mode 100644 client/me/purchases/billing-history-data-view/test/paginate-transactions.ts delete mode 100644 client/me/purchases/billing-history-data-view/test/tax.tsx delete mode 100644 client/me/purchases/billing-history-data-view/test/utils.ts delete mode 100644 client/me/purchases/billing-history-data-view/utils.tsx delete mode 100644 client/me/purchases/billing-history-data-view/vat-vendor-details.tsx rename client/me/purchases/{billing-history-data-view/billing-history-list.tsx => billing-history/billing-history-list-data-view.tsx} (99%) rename client/me/purchases/{billing-history-data-view/style.scss => billing-history/style-data-view.scss} (100%) diff --git a/client/me/purchases/billing-history-data-view/README.md b/client/me/purchases/billing-history-data-view/README.md deleted file mode 100644 index 1afa0cdf7468c..0000000000000 --- a/client/me/purchases/billing-history-data-view/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Billing History - -This is the Billing History React component that renders the /me/purchases/billing/ route. - -Supported routes: - -``` -/me/purchases/billing -/me/purchases/billing/:receiptId -``` diff --git a/client/me/purchases/billing-history-data-view/controller.js b/client/me/purchases/billing-history-data-view/controller.js deleted file mode 100644 index d7b9d14946fc9..0000000000000 --- a/client/me/purchases/billing-history-data-view/controller.js +++ /dev/null @@ -1,17 +0,0 @@ -import { createElement } from 'react'; -import BillingHistoryComponent from 'calypso/me/purchases/billing-history/main'; -import Receipt from './receipt'; - -export function billingHistory( context, next ) { - context.primary = createElement( BillingHistoryComponent ); - next(); -} - -export function transaction( context, next ) { - const receiptId = parseInt( context.params.receiptId, 10 ); - - if ( receiptId ) { - context.primary = createElement( Receipt, { transactionId: receiptId } ); - } - next(); -} diff --git a/client/me/purchases/billing-history-data-view/main.tsx b/client/me/purchases/billing-history-data-view/main.tsx deleted file mode 100644 index c07a37713ce16..0000000000000 --- a/client/me/purchases/billing-history-data-view/main.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import config from '@automattic/calypso-config'; -import { CompactCard, Card } from '@automattic/components'; -import { useTranslate } from 'i18n-calypso'; -import DocumentHead from 'calypso/components/data/document-head'; -import QueryBillingTransactions from 'calypso/components/data/query-billing-transactions'; -import InlineSupportLink from 'calypso/components/inline-support-link'; -import Main from 'calypso/components/main'; -import NavigationHeader from 'calypso/components/navigation-header'; -import { useGeoLocationQuery } from 'calypso/data/geo/use-geolocation-query'; -import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; -import BillingHistoryList from 'calypso/me/purchases/billing-history/billing-history-list'; -import { vatDetails as vatDetailsPath, billingHistoryReceipt } from 'calypso/me/purchases/paths'; -import PurchasesNavigation from 'calypso/me/purchases/purchases-navigation'; -import titles from 'calypso/me/purchases/titles'; -import useVatDetails from 'calypso/me/purchases/vat-info/use-vat-details'; -import { useTaxName } from 'calypso/my-sites/checkout/src/hooks/use-country-list'; - -import './style.scss'; -import '@wordpress/dataviews/build-style/style.css'; - -export function BillingHistoryContent( { - siteId = null, - getReceiptUrlFor = billingHistoryReceipt, -}: { - siteId: number | null; - getReceiptUrlFor: ( receiptId: string | number ) => string; -} ) { - return ( - - - - ); -} - -function BillingHistory() { - const translate = useTranslate(); - const { vatDetails } = useVatDetails(); - const { data: geoData } = useGeoLocationQuery(); - const taxName = useTaxName( vatDetails.country ?? geoData?.country_short ?? 'GB' ); - - const genericTaxName = - /* translators: This is a generic name for taxes to use when we do not know the user's country. */ - translate( 'tax (VAT/GST/CT)' ); - const fallbackTaxName = genericTaxName; - /* translators: %s is the name of taxes in the country (eg: "VAT" or "GST"). */ - const editVatText = translate( 'Edit %s details', { - textOnly: true, - args: [ taxName ?? fallbackTaxName ], - } ); - /* translators: %s is the name of taxes in the country (eg: "VAT" or "GST"). */ - const addVatText = translate( 'Add %s details', { - textOnly: true, - args: [ taxName ?? fallbackTaxName ], - } ); - const vatText = vatDetails.id ? editVatText : addVatText; - - return ( -
    - - - , - }, - } - ) } - /> - - - - - { config.isEnabled( 'me/vat-details' ) && ( - { vatText } - ) } -
    - ); -} -export default BillingHistory; diff --git a/client/me/purchases/billing-history-data-view/receipt.tsx b/client/me/purchases/billing-history-data-view/receipt.tsx deleted file mode 100644 index 1a38a16c7dd74..0000000000000 --- a/client/me/purchases/billing-history-data-view/receipt.tsx +++ /dev/null @@ -1,728 +0,0 @@ -import config from '@automattic/calypso-config'; -import page from '@automattic/calypso-router'; -import { Button, Card, FormLabel } from '@automattic/components'; -import { formatCurrency } from '@automattic/format-currency'; -import { IntroductoryOfferTerms } from '@automattic/shopping-cart'; -import { - LineItemCostOverrideForDisplay, - doesIntroductoryOfferHaveDifferentTermLengthThanProduct, - getIntroductoryOfferIntervalDisplay, - isUserVisibleCostOverride, -} from '@automattic/wpcom-checkout'; -import clsx from 'clsx'; -import { localize, useTranslate } from 'i18n-calypso'; -import { Component, useState, useCallback } from 'react'; -import { connect } from 'react-redux'; -import DocumentHead from 'calypso/components/data/document-head'; -import QueryBillingTransaction from 'calypso/components/data/query-billing-transaction'; -import HeaderCake from 'calypso/components/header-cake'; -import { withLocalizedMoment, useLocalizedMoment } from 'calypso/components/localized-moment'; -import Main from 'calypso/components/main'; -import NavigationHeader from 'calypso/components/navigation-header'; -import TextareaAutosize from 'calypso/components/textarea-autosize'; -import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; -import { PARTNER_PAYPAL_EXPRESS, PARTNER_PAYPAL_PPCP } from 'calypso/lib/checkout/payment-methods'; -import { billingHistory, vatDetails as vatDetailsPath } from 'calypso/me/purchases/paths'; -import titles from 'calypso/me/purchases/titles'; -import useVatDetails from 'calypso/me/purchases/vat-info/use-vat-details'; -import { useTaxName } from 'calypso/my-sites/checkout/src/hooks/use-country-list'; -import { useDispatch } from 'calypso/state'; -import { recordGoogleEvent } from 'calypso/state/analytics/actions'; -import { sendBillingReceiptEmail } from 'calypso/state/billing-transactions/actions'; -import { - clearBillingTransactionError, - requestBillingTransaction, -} from 'calypso/state/billing-transactions/individual-transactions/actions'; -import getPastBillingTransaction from 'calypso/state/selectors/get-past-billing-transaction'; -import isPastBillingTransactionError from 'calypso/state/selectors/is-past-billing-transaction-error'; -import { - getTransactionTermLabel, - groupDomainProducts, - renderTransactionQuantitySummary, - renderDomainTransactionVolumeSummary, - transactionIncludesTax, -} from './utils'; -import { VatVendorDetails } from './vat-vendor-details'; -import type { - BillingTransaction, - BillingTransactionItem, - ReceiptCostOverride, -} from 'calypso/state/billing-transactions/types'; -import type { IAppState } from 'calypso/state/types'; -import type { LocalizeProps } from 'i18n-calypso'; -import type { FormEvent } from 'react'; - -import './style.scss'; - -interface BillingReceiptProps { - transactionId: number; - recordGoogleEvent: ( key: string, message: string ) => void; - clearBillingTransactionError: ( transactionId: number ) => void; -} - -interface BillingReceiptConnectedProps { - transactionFetchError?: string; - transaction: BillingTransaction | undefined; - translate: LocalizeProps[ 'translate' ]; -} - -class BillingReceipt extends Component< BillingReceiptProps & BillingReceiptConnectedProps > { - componentDidMount() { - this.redirectIfInvalidTransaction(); - } - - componentDidUpdate() { - this.redirectIfInvalidTransaction(); - } - - recordClickEvent = ( action: string ) => { - this.props.recordGoogleEvent( 'Me', 'Clicked on ' + action ); - }; - - handlePrintLinkClick = () => { - this.recordClickEvent( 'Print Receipt Button in Billing History Receipt' ); - window.print(); - }; - - redirectIfInvalidTransaction() { - const { transactionFetchError, transactionId } = this.props; - - if ( ! transactionFetchError ) { - return; - } - - this.props.clearBillingTransactionError( transactionId ); - page.redirect( billingHistory ); - } - - render() { - const { transaction, transactionId, translate } = this.props; - - return ( -
    - - - - - - - - - - { transaction ? ( - - ) : ( - - ) } -
    - ); - } -} - -export function ReceiptBody( { - transaction, - handlePrintLinkClick, -}: { - transaction: BillingTransaction; - handlePrintLinkClick: () => void; -} ) { - const translate = useTranslate(); - const moment = useLocalizedMoment(); - const title = translate( 'Visit %(url)s', { args: { url: transaction.url }, textOnly: true } ); - const serviceLink = ; - - return ( -
    - -
    - { -

    - { translate( '{{link}}%(service)s{{/link}} {{small}}by %(organization)s{{/small}}', { - components: { - link: serviceLink, - small: , - }, - args: { - service: transaction.service, - organization: transaction.org, - }, - comment: - 'This string is "Service by Organization". ' + - 'The {{link}} and {{small}} add html styling and attributes. ' + - 'Screenshot: https://cloudup.com/isX-WEFYlOs', - } ) } - { transaction.address } -

    - - { moment( transaction.date ).format( 'll' ) } - -
    -
      -
    • - { translate( 'Receipt ID' ) } - { transaction.id } -
    • - - - { transaction.cc_num !== 'XXXX' ? ( - - ) : ( - - ) } - { config.isEnabled( 'me/vat-details' ) && } -
    - - -
    - -
    -
    -
    - ); -} - -function ReceiptTransactionId( { transaction }: { transaction: BillingTransaction } ) { - const translate = useTranslate(); - if ( ! transaction.pay_ref ) { - return null; - } - - return ( -
  • - { translate( 'Transaction ID' ) } - { transaction.pay_ref } -
  • - ); -} - -function ReceiptPaymentMethod( { transaction }: { transaction: BillingTransaction } ) { - const translate = useTranslate(); - let text; - - if ( - transaction.pay_part === PARTNER_PAYPAL_EXPRESS || - transaction.pay_part === PARTNER_PAYPAL_PPCP - ) { - text = translate( 'PayPal' ); - } else if ( 'XXXX' !== transaction.cc_num ) { - text = translate( '%(cardType)s ending in %(cardNum)s', { - args: { - cardType: - transaction.cc_display_brand?.replace( '_', ' ' ).toUpperCase() ?? - transaction.cc_type.toUpperCase(), - cardNum: transaction.cc_num, - }, - } ); - } else { - return null; - } - - return ( -
  • - { translate( 'Payment Method' ) } - { text } -
  • - ); -} - -function UserVatDetails( { transaction }: { transaction: BillingTransaction } ) { - const translate = useTranslate(); - const { vatDetails, isLoading, fetchError } = useVatDetails(); - const reduxDispatch = useDispatch(); - - const getEmailReceiptLinkClickHandler = ( receiptId: string ) => { - return ( event: FormEvent< HTMLFormElement > ) => { - event.preventDefault(); - reduxDispatch( recordGoogleEvent( 'Me', 'Clicked on Receipt Email Button' ) ); - reduxDispatch( sendBillingReceiptEmail( receiptId ) ); - }; - }; - - if ( isLoading || fetchError || ! vatDetails.id ) { - return null; - } - - return ( -
  • - { translate( 'VAT Details' ) } - - { translate( - '{{noPrint}}You can edit your VAT details {{vatDetailsLink}}on this page{{/vatDetailsLink}}. {{/noPrint}}This is not an official VAT receipt. For an official VAT receipt, {{emailReceiptLink}}email yourself a copy{{/emailReceiptLink}}.', - { - components: { - noPrint: , - vatDetailsLink: , - emailReceiptLink: ( -
  • - ); -} - -function VatDetails( { transaction }: { transaction: BillingTransaction } ) { - return ( - <> - - - - ); -} - -function getDiscountReasonForIntroductoryOffer( - product: BillingTransactionItem, - terms: IntroductoryOfferTerms, - translate: ReturnType< typeof useTranslate >, - allowFreeText: boolean, - isPriceIncrease: boolean -): string { - return getIntroductoryOfferIntervalDisplay( { - translate, - intervalUnit: terms.interval_unit, - intervalCount: terms.interval_count, - isFreeTrial: product.amount_integer === 0 && allowFreeText, - isPriceIncrease, - context: 'checkout', - remainingRenewalsUsingOffer: terms.transition_after_renewal_count, - } ); -} - -function makeIntroductoryOfferCostOverrideUnique( - costOverride: ReceiptCostOverride, - product: BillingTransactionItem, - translate: ReturnType< typeof useTranslate > -): ReceiptCostOverride { - // Replace introductory offer cost override text with wording specific to - // that offer. - if ( 'introductory-offer' === costOverride.override_code && product.introductory_offer_terms ) { - return { - ...costOverride, - human_readable_reason: getDiscountReasonForIntroductoryOffer( - product, - product.introductory_offer_terms, - translate, - true, - costOverride.new_price_integer > costOverride.old_price_integer - ), - }; - } - return costOverride; -} - -function filterCostOverridesForReceiptItem( - item: BillingTransactionItem, - translate: ReturnType< typeof useTranslate > -): LineItemCostOverrideForDisplay[] { - return item.cost_overrides - .filter( ( costOverride ) => isUserVisibleCostOverride( costOverride ) ) - .map( ( costOverride ) => - makeIntroductoryOfferCostOverrideUnique( costOverride, item, translate ) - ) - .map( ( costOverride ) => { - // Introductory offer discounts with term lengths that differ from - // the term length of the product (eg: a 3 month discount for an - // annual plan) need to be displayed differently because the - // discount is only temporary and the user will still be charged - // the remainder before the next renewal. - if ( - doesIntroductoryOfferHaveDifferentTermLengthThanProduct( - item.cost_overrides, - item.introductory_offer_terms, - item.months_per_renewal_interval - ) - ) { - return { - humanReadableReason: costOverride.human_readable_reason, - overrideCode: costOverride.override_code, - }; - } - return { - humanReadableReason: costOverride.human_readable_reason, - overrideCode: costOverride.override_code, - discountAmount: costOverride.old_price_integer - costOverride.new_price_integer, - }; - } ); -} - -function areReceiptItemDiscountsAccurate( receiptDate: string ): boolean { - const date = new Date( receiptDate ); - const receiptDateUnix = date.getTime() / 1000; - // D129863-code and D133350-code fixed volume discounts. Before that, cost - // override tags may be incomplete. The latter was merged on Jan 2, 2024, - // 17:54 UTC. - const receiptTagsAccurateAsOf = 1704218040; - return receiptDateUnix > receiptTagsAccurateAsOf; -} - -function ReceiptItemDiscountIntroductoryOfferDate( { item }: { item: BillingTransactionItem } ) { - const translate = useTranslate(); - if ( ! item.introductory_offer_terms?.enabled ) { - return null; - } - if ( - ! doesIntroductoryOfferHaveDifferentTermLengthThanProduct( - item.cost_overrides, - item.introductory_offer_terms, - item.months_per_renewal_interval - ) - ) { - return null; - } - - return ( -
    -
    - { translate( 'Amount paid in transaction: %(price)s', { - args: { - price: formatCurrency( item.amount_integer, item.currency, { - isSmallestUnit: true, - stripZeros: true, - } ), - }, - } ) } -
    -
    - ); -} - -function ReceiptItemDiscounts( { - item, - receiptDate, -}: { - item: BillingTransactionItem; - receiptDate: string; -} ) { - const shouldShowDiscount = areReceiptItemDiscountsAccurate( receiptDate ); - const translate = useTranslate(); - return ( -
      - { filterCostOverridesForReceiptItem( item, translate ).map( ( costOverride ) => { - const formattedDiscountAmount = - shouldShowDiscount && costOverride.discountAmount - ? formatCurrency( -costOverride.discountAmount, item.currency, { - isSmallestUnit: true, - stripZeros: true, - } ) - : ''; - if ( - doesIntroductoryOfferHaveDifferentTermLengthThanProduct( - item.cost_overrides, - item.introductory_offer_terms, - item.months_per_renewal_interval - ) - ) { - return ( -
    • -
      { costOverride.humanReadableReason }
      - -
    • - ); - } - return ( -
    • - { costOverride.humanReadableReason } - { formattedDiscountAmount } -
    • - ); - } ) } -
    - ); -} - -/** - * Calculate the original cost for a receipt item by looking at any cost - * overrides. - * - * Returns the number in the currency's smallest unit. - */ -function getReceiptItemOriginalCost( item: BillingTransactionItem ): number { - if ( item.type === 'refund' ) { - return item.subtotal_integer; - } - const originalCostOverrides = item.cost_overrides.filter( - ( override ) => override.does_override_original_cost - ); - if ( originalCostOverrides.length > 0 ) { - const lastOriginalCostOverride = originalCostOverrides.pop(); - if ( lastOriginalCostOverride ) { - return lastOriginalCostOverride.new_price_integer; - } - } - if ( item.cost_overrides.length > 0 ) { - const firstOverride = item.cost_overrides[ 0 ]; - if ( firstOverride ) { - return firstOverride.old_price_integer; - } - } - return item.subtotal_integer; -} - -function ReceiptItemTaxes( { transaction }: { transaction: BillingTransaction } ) { - const translate = useTranslate(); - const taxName = useTaxName( transaction.tax_country_code ); - - if ( ! transactionIncludesTax( transaction ) ) { - return null; - } - - return ( -
    - { taxName ?? translate( 'Tax' ) } - - { formatCurrency( transaction.tax_integer, transaction.currency, { - isSmallestUnit: true, - stripZeros: true, - } ) } - -
    - ); -} - -function ReceiptLineItem( { - item, - transaction, -}: { - item: BillingTransactionItem; - transaction: BillingTransaction; -} ) { - const translate = useTranslate(); - const termLabel = getTransactionTermLabel( item, translate ); - const shouldShowDiscount = areReceiptItemDiscountsAccurate( transaction.date ); - const formattedAmount = formatCurrency( - shouldShowDiscount ? getReceiptItemOriginalCost( item ) : item.subtotal_integer, - item.currency, - { - isSmallestUnit: true, - stripZeros: true, - } - ); - return ( - <> - - - { item.variation } - ({ item.type_localized }) - { termLabel && { termLabel } } - { item.domain && { item.domain } } - { item.licensed_quantity && ( - { renderTransactionQuantitySummary( item, translate ) } - ) } - { item.volume && { renderDomainTransactionVolumeSummary( item, translate ) } } - - - { doesIntroductoryOfferHaveDifferentTermLengthThanProduct( - item.cost_overrides, - item.introductory_offer_terms, - item.months_per_renewal_interval - ) ? ( - { formattedAmount } - ) : ( - formattedAmount - ) } - { transaction.credit && ( - { translate( 'Refund' ) } - ) } - - - - - - - - - ); -} - -function ReceiptLineItems( { transaction }: { transaction: BillingTransaction } ) { - const translate = useTranslate(); - const groupedTransactionItems = groupDomainProducts( transaction.items, translate ); - - return ( -
    -

    { translate( 'Order summary' ) }

    - - - - - - - - - { groupedTransactionItems.map( ( item ) => ( - - ) ) } - - - - - - - - - - -
    { translate( 'Description' ) }{ translate( 'Amount' ) }
    - -
    - - { translate( 'Total paid:', { comment: 'Total amount paid for product' } ) } - - - { formatCurrency( transaction.amount_integer, transaction.currency, { - isSmallestUnit: true, - stripZeros: true, - } ) } -
    -
    - ); -} - -function ReceiptDetails( { transaction }: { transaction: BillingTransaction } ) { - if ( ! transaction.cc_name && ! transaction.cc_email ) { - return null; - } - - return ( -
  • - - -
  • - ); -} - -function EmptyReceiptDetails() { - // When the content of the text area is empty, hide the "Billing Details" label for printing. - const [ hideDetailsLabelOnPrint, setHideDetailsLabelOnPrint ] = useState( true ); - const onChange = useCallback( - ( e: React.ChangeEvent< HTMLTextAreaElement > ) => { - const value = e.target.value.trim(); - if ( hideDetailsLabelOnPrint && value.length > 0 ) { - setHideDetailsLabelOnPrint( false ); - } else if ( ! hideDetailsLabelOnPrint && value.length === 0 ) { - setHideDetailsLabelOnPrint( true ); - } - }, - [ hideDetailsLabelOnPrint, setHideDetailsLabelOnPrint ] - ); - - return ( -
  • - - -
  • - ); -} - -export function ReceiptPlaceholder() { - return ( - -
    -
    -
    -
    - -
    -
    -
    -
    - - ); -} - -function ReceiptLabels( { hideDetailsLabelOnPrint }: { hideDetailsLabelOnPrint?: boolean } ) { - const translate = useTranslate(); - - let labelContent = translate( - 'Use this field to add your billing information (eg. VAT number, business address) before printing.' - ); - if ( config.isEnabled( 'me/vat-details' ) ) { - labelContent = translate( - 'Use this field to add your billing information (eg. business address) before printing.' - ); - } - return ( -
    - - { translate( 'Billing Details' ) } - -
    - { labelContent } -
    -
    - ); -} - -export function ReceiptTitle( { backHref }: { backHref: string } ) { - const translate = useTranslate(); - return { translate( 'Receipt' ) }; -} - -export default connect( - ( state: IAppState, { transactionId }: { transactionId: number } ) => { - const transaction = getPastBillingTransaction( state, transactionId ); - return { - transaction: transaction && 'service' in transaction ? transaction : undefined, - transactionFetchError: isPastBillingTransactionError( state, transactionId ), - }; - }, - { - clearBillingTransactionError, - recordGoogleEvent, - requestBillingTransaction, - } -)( localize( withLocalizedMoment( BillingReceipt ) ) ); diff --git a/client/me/purchases/billing-history-data-view/test/filter-transactions.ts b/client/me/purchases/billing-history-data-view/test/filter-transactions.ts deleted file mode 100644 index f2911858acdd4..0000000000000 --- a/client/me/purchases/billing-history-data-view/test/filter-transactions.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { cloneDeep } from 'lodash'; -import { - BillingTransaction, - BillingTransactionItem, -} from 'calypso/state/billing-transactions/types'; -import getBillingTransactionFilters from 'calypso/state/selectors/get-billing-transaction-filters'; -import { filterTransactions } from '../filter-transactions'; - -const mockTransaction: BillingTransaction = { - currency: 'USD', - address: '', - amount: '', - amount_integer: 0, - tax_country_code: '', - cc_email: '', - cc_name: '', - cc_num: '', - cc_type: '', - credit: '', - date: '', - desc: '', - icon: '', - id: '', - items: [], - org: '', - pay_part: '', - pay_ref: '', - service: '', - subtotal: '', - subtotal_integer: 0, - support: '', - tax: '', - tax_integer: 0, - url: '', -}; - -const mockItem: BillingTransactionItem = { - id: '', - type: '', - type_localized: '', - domain: '', - site_id: '', - subtotal: '', - subtotal_integer: 0, - tax_integer: 0, - amount_integer: 0, - tax: '', - amount: '', - raw_subtotal: 0, - raw_tax: 0, - raw_amount: 0, - currency: '', - licensed_quantity: null, - new_quantity: null, - product: '', - product_slug: '', - variation: '', - variation_slug: '', - months_per_renewal_interval: 0, - wpcom_product_slug: '', -}; - -const past: BillingTransaction[] = [ - { - ...mockTransaction, - date: '2018-05-01T12:00:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-04-11T13:11:27', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$5.75', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$8.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-11T21:00:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$5.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-15T10:39:27', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.86', - type: 'new purchase', - variation: 'Variation1', - }, - { - ...mockItem, - amount: '$1.23', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-13T16:10:45', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-01-10T14:24:38', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.20', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-10T10:30:38', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.75', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-01T07:20:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$9.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-11-24T05:13:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$8.40', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-01-01T00:00:00', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$2.40', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, -]; - -const state = { - billingTransactions: { - items: { - past, - }, - ui: { past: undefined, upcoming: undefined }, - }, -}; - -describe( 'filterTransactions()', () => { - describe( 'date filter', () => { - test( 'returns all transactions when filtering by newest', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: null, operator: null }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns transactions filtered by month', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-03', operator: 'equal' }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result ).toHaveLength( 3 ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); - expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 2 ); - expect( new Date( result[ 2 ].date ).getMonth() ).toBe( 2 ); - } ); - - test( 'returns transactions before the month set in the filter', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2017-12', operator: 'before' }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 10 ); - expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 0 ); - } ); - } ); - - describe( 'app filter', () => { - test( 'returns all transactions when the filter is empty', () => { - const filter = getBillingTransactionFilters( state as any, 'past' ); - const result = filterTransactions( state.billingTransactions.items.past, filter, null ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns transactions filtered by app name', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - app: 'Store Services', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - result.forEach( ( transaction ) => { - expect( transaction.service ).toEqual( 'Store Services' ); - } ); - } ); - } ); - - describe( 'search query', () => { - test( 'query matches a field in the root transaction object', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - query: 'mastercard', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( state.billingTransactions.items.past, filter, null ); - result.forEach( ( transaction ) => { - expect( transaction.cc_type ).toEqual( 'mastercard' ); - } ); - } ); - - test( 'query matches date of a transaction', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - query: 'may 1', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - result.forEach( ( transaction ) => { - expect( transaction.date ).toBe( '2018-05-01T12:00:00' ); - } ); - } ); - - test( 'query matches a field in the transaction items array', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - query: '$3.50', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); - expect( result[ 1 ].items ).toMatchObject( [ { amount: '$3.50' }, { amount: '$5.00' } ] ); - expect( result[ 2 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); - } ); - } ); - - describe( 'filter combinations', () => { - test( 'date and app filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-03', operator: 'equal' }, - app: 'Store Services', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); - expect( result[ 0 ].service ).toEqual( 'Store Services' ); - expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 2 ); - expect( result[ 1 ].service ).toEqual( 'Store Services' ); - } ); - - test( 'app and query filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - app: 'Store Services', - query: '$3.50', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' }, { amount: '$5.00' } ] ); - expect( result[ 0 ].service ).toEqual( 'Store Services' ); - } ); - - test( 'date and query filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-05', operator: 'equal' }, - query: '$3.50', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 4 ); - } ); - - test( 'app, date and query filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-03', operator: 'equal' }, - query: 'visa', - app: 'WordPress.com', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].cc_type ).toEqual( 'visa' ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); - expect( result[ 0 ].service ).toEqual( 'WordPress.com' ); - } ); - } ); - - describe( 'no results', () => { - test( 'should return all expected meta fields including an empty transactions array', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2019-01', operator: 'equal' }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result ).toEqual( [] ); - } ); - } ); -} ); diff --git a/client/me/purchases/billing-history-data-view/test/paginate-transactions.ts b/client/me/purchases/billing-history-data-view/test/paginate-transactions.ts deleted file mode 100644 index de5205c611666..0000000000000 --- a/client/me/purchases/billing-history-data-view/test/paginate-transactions.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { cloneDeep } from 'lodash'; -import { - BillingTransaction, - BillingTransactionItem, -} from 'calypso/state/billing-transactions/types'; -import { paginateTransactions } from '../filter-transactions'; - -const mockTransaction: BillingTransaction = { - currency: 'USD', - address: '', - amount: '', - amount_integer: 0, - tax_country_code: '', - cc_email: '', - cc_name: '', - cc_num: '', - cc_type: '', - credit: '', - date: '', - desc: '', - icon: '', - id: '', - items: [], - org: '', - pay_part: '', - pay_ref: '', - service: '', - subtotal: '', - subtotal_integer: 0, - support: '', - tax: '', - tax_integer: 0, - url: '', -}; - -const mockItem: BillingTransactionItem = { - id: '', - type: '', - type_localized: '', - domain: '', - site_id: '', - subtotal: '', - subtotal_integer: 0, - tax_integer: 0, - amount_integer: 0, - tax: '', - amount: '', - raw_subtotal: 0, - raw_tax: 0, - raw_amount: 0, - currency: '', - licensed_quantity: null, - new_quantity: null, - product: '', - product_slug: '', - variation: '', - variation_slug: '', - months_per_renewal_interval: 0, - wpcom_product_slug: '', -}; - -const past: BillingTransaction[] = [ - { - ...mockTransaction, - date: '2018-05-01T12:00:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-04-11T13:11:27', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$5.75', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$8.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-11T21:00:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$5.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-15T10:39:27', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.86', - type: 'new purchase', - variation: 'Variation1', - }, - { - ...mockItem, - amount: '$1.23', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-13T16:10:45', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-01-10T14:24:38', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.20', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-10T10:30:38', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.75', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-01T07:20:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$9.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-11-24T05:13:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$8.40', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-01-01T00:00:00', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$2.40', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, -]; - -const state = { - billingTransactions: { - items: { - past, - }, - }, -}; - -describe( 'paginateTransactions()', () => { - test( 'returns all transactions when there are fewer than the page limit', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length + 1; - const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns all transactions when there are the same number as the page limit', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length; - const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns a page from all transactions when there are more than the page limit', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length - 1; - const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 0, pageSize ) ); - } ); - - test( 'returns the first page of transactions when no page is specified', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length - 1; - const result = paginateTransactions( testState.billingTransactions.items.past, null, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 0, pageSize ) ); - } ); - - test( 'returns the second page of transactions when the second page is specified', () => { - const testState = cloneDeep( state ); - const result = paginateTransactions( testState.billingTransactions.items.past, 2, 3 ); - expect( result.length ).toEqual( 3 ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 3, 6 ) ); - expect( result[ 0 ].date ).toEqual( '2018-03-15T10:39:27' ); - expect( result[ 1 ].date ).toEqual( '2018-03-13T16:10:45' ); - expect( result[ 2 ].date ).toEqual( '2018-01-10T14:24:38' ); - } ); - - test( 'returns an abbreviated last page of transactions when the last page is specified', () => { - const testState = cloneDeep( state ); - const result = paginateTransactions( testState.billingTransactions.items.past, 4, 3 ); - expect( result.length ).toEqual( 1 ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 9, 10 ) ); - expect( result[ 0 ].date ).toEqual( '2017-01-01T00:00:00' ); - } ); -} ); diff --git a/client/me/purchases/billing-history-data-view/test/tax.tsx b/client/me/purchases/billing-history-data-view/test/tax.tsx deleted file mode 100644 index 81bbca1d8938f..0000000000000 --- a/client/me/purchases/billing-history-data-view/test/tax.tsx +++ /dev/null @@ -1,272 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; -import { Provider as ReduxProvider } from 'react-redux'; -import { - countryList, - createTestReduxStore, - mockGetSupportedCountriesEndpoint, -} from 'calypso/my-sites/checkout/src/test/util'; -import { - BillingTransaction, - BillingTransactionItem, -} from 'calypso/state/billing-transactions/types'; -import { TransactionAmount, transactionIncludesTax } from '../utils'; - -const mockTransaction: BillingTransaction = { - currency: 'USD', - address: '', - amount: '', - amount_integer: 0, - tax_country_code: '', - cc_email: '', - cc_name: '', - cc_num: '', - cc_type: '', - credit: '', - date: '', - desc: '', - icon: '', - id: '', - items: [], - org: '', - pay_part: '', - pay_ref: '', - service: '', - subtotal: '', - subtotal_integer: 0, - support: '', - tax: '', - tax_integer: 0, - url: '', -}; - -const mockItem: BillingTransactionItem = { - id: '', - type: '', - type_localized: '', - domain: '', - site_id: '', - subtotal: '', - subtotal_integer: 0, - tax_integer: 0, - amount_integer: 0, - tax: '', - amount: '', - raw_subtotal: 0, - raw_tax: 0, - raw_amount: 0, - currency: '', - licensed_quantity: null, - new_quantity: null, - product: '', - product_slug: '', - variation: '', - variation_slug: '', - months_per_renewal_interval: 0, - wpcom_product_slug: '', - cost_overrides: [], -}; - -describe( 'transactionIncludesTax', () => { - test( 'returns true for a transaction with tax', () => { - const transaction: BillingTransaction = { - ...mockTransaction, - subtotal: '$36.00', - tax: '$2.48', - amount: '$38.48', - subtotal_integer: 3600, - tax_integer: 248, - amount_integer: 3848, - items: [ - { - ...mockItem, - raw_tax: 2.48, - tax_integer: 248, - }, - ], - }; - - expect( transactionIncludesTax( transaction ) ).toBe( true ); - } ); - - test( 'returns false for a transaction without tax values', () => { - const transaction = { - ...mockTransaction, - subtotal: '$36.00', - amount: '$38.48', - subtotal_integer: 3600, - amount_integer: 3848, - items: [ - { - ...mockItem, - }, - ], - }; - - expect( transactionIncludesTax( transaction ) ).toBe( false ); - } ); - - test( 'returns false for a transaction with zero tax values', () => { - const transaction = { - ...mockTransaction, - subtotal: '$36.00', - tax: '$0.00', - amount: '$38.48', - subtotal_integer: 3600, - amount_integer: 3848, - items: [ - { - ...mockItem, - raw_tax: 0, - }, - ], - }; - expect( transactionIncludesTax( transaction ) ).toBe( false ); - } ); - - test( 'returns false for a transaction without zero tax values in another currency', () => { - const transaction = { - ...mockTransaction, - currency: 'EUR', - subtotal: '€36.00', - tax: '€0.00', - amount: '€38.48', - subtotal_integer: 3600, - amount_integer: 3848, - items: [ - { - ...mockItem, - raw_tax: 0, - }, - ], - }; - - expect( transactionIncludesTax( transaction ) ).toBe( false ); - } ); -} ); - -test( 'tax shown if available', async () => { - const transaction = { - ...mockTransaction, - subtotal: '$36.00', - tax: '$2.48', - amount: '$38.48', - subtotal_integer: 3600, - tax_integer: 248, - amount_integer: 3848, - items: [ - { - ...mockItem, - raw_tax: 2.48, - tax_integer: 248, - }, - ], - }; - const store = createTestReduxStore(); - const queryClient = new QueryClient(); - mockGetSupportedCountriesEndpoint( countryList ); - - render( - - - - - - ); - expect( await screen.findByText( /tax/ ) ).toBeInTheDocument(); -} ); - -test( 'tax includes', async () => { - const transaction = { - ...mockTransaction, - subtotal: '$36.00', - tax: '$2.48', - amount: '$38.48', - subtotal_integer: 3600, - tax_integer: 248, - amount_integer: 3848, - items: [ - { - ...mockItem, - raw_tax: 2.48, - tax_integer: 248, - }, - ], - }; - - const store = createTestReduxStore(); - const queryClient = new QueryClient(); - mockGetSupportedCountriesEndpoint( countryList ); - - render( - - - - - - ); - expect( await screen.findByText( `(includes ${ transaction.tax } tax)` ) ).toBeInTheDocument(); -} ); - -test( 'tax includes with localized tax name', async () => { - const transaction = { - ...mockTransaction, - subtotal: '$36.00', - tax: '$2.48', - amount: '$38.48', - tax_country_code: 'GB', - subtotal_integer: 3600, - tax_integer: 248, - amount_integer: 3848, - items: [ - { - ...mockItem, - raw_tax: 2.48, - tax_integer: 248, - }, - ], - }; - - const store = createTestReduxStore(); - const queryClient = new QueryClient(); - mockGetSupportedCountriesEndpoint( countryList ); - - render( - - - - - - ); - expect( await screen.findByText( `(includes ${ transaction.tax } VAT)` ) ).toBeInTheDocument(); -} ); - -test( 'tax hidden if not available', async () => { - const transaction = { - ...mockTransaction, - subtotal: '$36.00', - tax: '$0.00', - amount: '$36.00', - subtotal_integer: 3600, - amount_integer: 3600, - items: [ { ...mockItem } ], - }; - - const store = createTestReduxStore(); - const queryClient = new QueryClient(); - mockGetSupportedCountriesEndpoint( countryList ); - - render( - - - - - - ); - expect( await screen.findByText( `$36` ) ).toBeInTheDocument(); - expect( screen.queryByText( `(includes ${ transaction.tax } VAT)` ) ).not.toBeInTheDocument(); -} ); diff --git a/client/me/purchases/billing-history-data-view/test/utils.ts b/client/me/purchases/billing-history-data-view/test/utils.ts deleted file mode 100644 index b73c8785c3c51..0000000000000 --- a/client/me/purchases/billing-history-data-view/test/utils.ts +++ /dev/null @@ -1,354 +0,0 @@ -import deepFreeze from 'deep-freeze'; -import { groupDomainProducts } from '../utils'; - -const ident = ( x ) => x; - -describe( 'utils', () => { - describe( '#groupDomainProducts()', () => { - test( 'should return non-domain items unchanged', () => { - const items = deepFreeze( [ { foo: 'bar', product_slug: 'foobar' } ] ); - const result = groupDomainProducts( items, ident ); - expect( result ).toEqual( items ); - } ); - - test( 'should return a single domain item unchanged', () => { - const items = deepFreeze( [ - { foo: 'bar', product_slug: 'foobar' }, - { product_slug: 'wp-domains', domain: 'foo.com', variation_slug: 'none' }, - ] ); - const expected = [ - { foo: 'bar', product_slug: 'foobar' }, - { - product_slug: 'wp-domains', - variation_slug: 'none', - domain: 'foo.com', - }, - ]; - const result = groupDomainProducts( items, ident ); - expect( result ).toEqual( expected ); - } ); - - test( 'should not group domain items with different domains', () => { - const items = deepFreeze( [ - { foo: 'bar', product_slug: 'foobar' }, - { - id: '2', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - cost_overrides: [], - }, - { - id: '3', - product_slug: 'wp-domains', - domain: 'bar.com', - variation_slug: 'wp-private-registration', - cost_overrides: [], - }, - ] ); - const expected = [ - { foo: 'bar', product_slug: 'foobar' }, - { - id: '2', - product_slug: 'wp-domains', - variation_slug: 'wp-private-registration', - domain: 'foo.com', - cost_overrides: [], - }, - { - id: '3', - product_slug: 'wp-domains', - variation_slug: 'wp-private-registration', - domain: 'bar.com', - cost_overrides: [], - }, - ]; - const result = groupDomainProducts( items, ident ); - expect( result ).toEqual( expected ); - expect( result.length ).toEqual( 3 ); - } ); - - test( 'should only return one domain item of multiple with the same domain', () => { - const items = deepFreeze( [ - { foo: 'bar', product_slug: 'foobar' }, - { - id: '2', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - cost_overrides: [], - }, - { - id: '3', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - cost_overrides: [], - }, - ] ); - const result = groupDomainProducts( items, ident ); - expect( result.length ).toEqual( 2 ); - } ); - - test( 'should sum the prices for multiple items with the same domain', () => { - const items = deepFreeze( [ - { foo: 'bar', product_slug: 'foobar' }, - { - id: '1', - product_slug: 'wp-domains', - domain: 'bar.com', - variation_slug: 'none', - currency: 'USD', - raw_amount: 2, - amount_integer: 200, - subtotal_integer: 210, - tax_integer: 10, - cost_overrides: [], - }, - { - id: '2', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'none', - currency: 'USD', - raw_amount: 3, - amount_integer: 300, - subtotal_integer: 310, - tax_integer: 10, - cost_overrides: [], - }, - { - id: '3', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - currency: 'USD', - raw_amount: 7, - amount_integer: 700, - subtotal_integer: 711, - tax_integer: 11, - cost_overrides: [], - }, - { - id: '4', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - currency: 'USD', - raw_amount: 9, - amount_integer: 900, - subtotal_integer: 912, - tax_integer: 12, - cost_overrides: [], - }, - ] ); - const result = groupDomainProducts( items, ident ); - expect( result[ 1 ].raw_amount ).toEqual( 2 ); - expect( result[ 1 ].amount_integer ).toEqual( 200 ); - expect( result[ 1 ].subtotal_integer ).toEqual( 210 ); - expect( result[ 1 ].tax_integer ).toEqual( 10 ); - expect( result[ 2 ].raw_amount ).toEqual( 19 ); - expect( result[ 2 ].amount_integer ).toEqual( 1900 ); - expect( result[ 2 ].subtotal_integer ).toEqual( 1933 ); - expect( result[ 2 ].tax_integer ).toEqual( 33 ); - } ); - - test( 'should sum the cost_overrides for multiple items with the same domain', () => { - const items = deepFreeze( [ - { foo: 'bar', product_slug: 'foobar' }, - { - id: '1', - product_slug: 'wp-domains', - domain: 'bar.com', - variation_slug: 'none', - currency: 'USD', - raw_amount: 2, - amount_integer: 200, - subtotal_integer: 210, - tax_integer: 10, - cost_overrides: [ - { - id: 'v12345', - human_readable_reason: 'Price change', - override_code: 'test-override', - does_override_original_cost: false, - old_price_integer: 100, - new_price_integer: 200, - }, - ], - }, - { - id: '2', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'none', - currency: 'USD', - raw_amount: 3, - amount_integer: 300, - subtotal_integer: 310, - tax_integer: 10, - cost_overrides: [ - { - id: 'v12345', - human_readable_reason: 'Price change', - override_code: 'test-override', - does_override_original_cost: false, - old_price_integer: 100, - new_price_integer: 200, - }, - { - id: 'v12347', - human_readable_reason: 'Price change 2', - override_code: 'test-override-2', - does_override_original_cost: false, - old_price_integer: 200, - new_price_integer: 300, - }, - ], - }, - { - id: '3', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - currency: 'USD', - raw_amount: 7, - amount_integer: 700, - subtotal_integer: 711, - tax_integer: 11, - cost_overrides: [ - { - id: 'v12345', - human_readable_reason: 'Price change', - override_code: 'test-override', - does_override_original_cost: false, - old_price_integer: 101, - new_price_integer: 301, - }, - { - id: 'v12346', - human_readable_reason: 'Price change 3', - override_code: 'test-override-3', - does_override_original_cost: false, - old_price_integer: 301, - new_price_integer: 700, - }, - ], - }, - { - id: '4', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - currency: 'USD', - raw_amount: 9, - amount_integer: 900, - subtotal_integer: 912, - tax_integer: 12, - cost_overrides: [ - { - id: 'v12345', - human_readable_reason: 'Price change', - override_code: 'test-override', - does_override_original_cost: false, - old_price_integer: 102, - new_price_integer: 900, - }, - ], - }, - ] ); - const result = groupDomainProducts( items, ident ); - expect( result[ 1 ].cost_overrides ).toEqual( [ - { - id: 'v12345', - human_readable_reason: 'Price change', - override_code: 'test-override', - does_override_original_cost: false, - old_price_integer: 100, - new_price_integer: 200, - }, - ] ); - expect( result[ 2 ].cost_overrides ).toEqual( [ - { - id: 'v12345', - human_readable_reason: 'Price change', - override_code: 'test-override', - does_override_original_cost: false, - old_price_integer: 303, - new_price_integer: 1401, - }, - { - id: 'v12347', - human_readable_reason: 'Price change 2', - override_code: 'test-override-2', - does_override_original_cost: false, - old_price_integer: 200, - new_price_integer: 300, - }, - { - id: 'v12346', - human_readable_reason: 'Price change 3', - override_code: 'test-override-3', - does_override_original_cost: false, - old_price_integer: 301, - new_price_integer: 700, - }, - ] ); - } ); - - test( 'should include the formatted, summed raw_amount as amount for multiple items with the same domain', () => { - const items = deepFreeze( [ - { foo: 'bar', product_slug: 'foobar' }, - { - id: '1', - product_slug: 'wp-domains', - domain: 'bar.com', - variation_slug: 'none', - amount: '$2.00', - currency: 'USD', - raw_amount: 2, - amount_integer: 200, - cost_overrides: [], - }, - { - id: '2', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'none', - amount: '$3.00', - currency: 'USD', - raw_amount: 3, - amount_integer: 300, - cost_overrides: [], - }, - { - id: '3', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - amount: '$7.00', - currency: 'USD', - raw_amount: 7, - amount_integer: 700, - cost_overrides: [], - }, - { - id: '4', - product_slug: 'wp-domains', - domain: 'foo.com', - variation_slug: 'wp-private-registration', - amount: '$9.00', - currency: 'USD', - raw_amount: 9, - amount_integer: 900, - cost_overrides: [], - }, - ] ); - const result = groupDomainProducts( items, ident ); - expect( result[ 1 ].amount ).toEqual( '$2.00' ); - expect( result[ 1 ].amount_integer ).toEqual( 200 ); - expect( result[ 2 ].amount ).toEqual( '$19' ); - expect( result[ 2 ].amount_integer ).toEqual( 1900 ); - } ); - } ); -} ); diff --git a/client/me/purchases/billing-history-data-view/utils.tsx b/client/me/purchases/billing-history-data-view/utils.tsx deleted file mode 100644 index ac05b779f88a3..0000000000000 --- a/client/me/purchases/billing-history-data-view/utils.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { - getPlanTermLabel, - isDIFMProduct, - isGoogleWorkspace, - isTitanMail, - isTieredVolumeSpaceAddon, -} from '@automattic/calypso-products'; -import formatCurrency from '@automattic/format-currency'; -import { LocalizeProps, useTranslate } from 'i18n-calypso'; -import { Fragment } from 'react'; -import { useTaxName } from 'calypso/my-sites/checkout/src/hooks/use-country-list'; -import { - BillingTransaction, - BillingTransactionItem, - ReceiptCostOverride, -} from 'calypso/state/billing-transactions/types'; - -interface GroupedDomainProduct { - product: BillingTransactionItem; - groupCount: number; -} - -export const groupDomainProducts = ( - originalItems: BillingTransactionItem[], - translate: LocalizeProps[ 'translate' ] -) => { - const domainProducts: BillingTransactionItem[] = []; - const otherProducts: BillingTransactionItem[] = []; - originalItems.forEach( ( item ) => { - if ( item.product_slug === 'wp-domains' ) { - domainProducts.push( item ); - } else { - otherProducts.push( item ); - } - } ); - - const groupedDomainProductsMap = domainProducts.reduce< - Map< BillingTransactionItem[ 'domain' ], GroupedDomainProduct > - >( ( groups, product ) => { - const existingGroup = groups.get( product.domain ); - if ( existingGroup ) { - const mergedOverrides: ReceiptCostOverride[] = []; - existingGroup.product.cost_overrides = existingGroup.product.cost_overrides.map( - ( existingGroupOverride ) => { - const productOverride = product.cost_overrides.find( - ( override ) => override.override_code === existingGroupOverride.override_code - ); - if ( productOverride ) { - mergedOverrides.push( productOverride ); - return { - ...existingGroupOverride, - new_price_integer: - existingGroupOverride.new_price_integer + productOverride.new_price_integer, - old_price_integer: - existingGroupOverride.old_price_integer + productOverride.old_price_integer, - }; - } - return existingGroupOverride; - } - ); - product.cost_overrides.forEach( ( override ) => { - if ( ! mergedOverrides.some( ( merged ) => merged.id === override.id ) ) { - existingGroup.product.cost_overrides.push( override ); - } - } ); - existingGroup.product.raw_amount += product.raw_amount; - existingGroup.product.amount_integer += product.amount_integer; - existingGroup.product.subtotal_integer += product.subtotal_integer; - existingGroup.product.tax_integer += product.tax_integer; - existingGroup.groupCount++; - } else { - const newGroup = { - product: { ...product }, - groupCount: 1, - }; - groups.set( product.domain, newGroup ); - } - - return groups; - }, new Map() ); - - const groupedDomainProducts: BillingTransactionItem[] = []; - - groupedDomainProductsMap.forEach( ( value ) => { - if ( value.groupCount === 1 ) { - groupedDomainProducts.push( value.product ); - return; - } - groupedDomainProducts.push( { - ...value.product, - amount: formatCurrency( value.product.amount_integer, value.product.currency, { - isSmallestUnit: true, - stripZeros: true, - } ), - variation: translate( 'Domain Registration' ), - } ); - } ); - - return [ ...otherProducts, ...groupedDomainProducts ]; -}; - -export function transactionIncludesTax( transaction: BillingTransaction ) { - if ( ! transaction || ! transaction.tax_integer ) { - return false; - } - - // Consider the whole transaction to include tax if any item does - return transaction.items.some( ( item ) => item.tax_integer > 0 ); -} - -export function TransactionAmount( { - transaction, -}: { - transaction: BillingTransaction; -} ): JSX.Element { - const translate = useTranslate(); - const taxName = useTaxName( transaction.tax_country_code ); - - if ( ! transactionIncludesTax( transaction ) ) { - return ( - <> - { formatCurrency( transaction.amount_integer, transaction.currency, { - isSmallestUnit: true, - stripZeros: false, - } ) } - - ); - } - - const includesTaxString = taxName - ? translate( '(includes %(taxAmount)s %(taxName)s)', { - args: { - taxAmount: formatCurrency( transaction.tax_integer, transaction.currency, { - isSmallestUnit: true, - stripZeros: false, - } ), - taxName, - }, - comment: - 'taxAmount is a localized price, like $12.34 | taxName is a localized tax, like VAT or GST', - } ) - : translate( '(includes %(taxAmount)s tax)', { - args: { - taxAmount: formatCurrency( transaction.tax_integer, transaction.currency, { - isSmallestUnit: true, - stripZeros: false, - } ), - }, - comment: 'taxAmount is a localized price, like $12.34', - } ); - - return ( - -
    - { formatCurrency( transaction.amount_integer, transaction.currency, { - isSmallestUnit: true, - stripZeros: true, - } ) } -
    -
    { includesTaxString }
    -
    - ); -} - -function renderTransactionQuantitySummaryForMailboxes( - licensed_quantity: number, - new_quantity: number, - isRenewal: boolean, - isUpgrade: boolean, - translate: LocalizeProps[ 'translate' ] -) { - if ( isRenewal ) { - return translate( 'Renewal for %(quantity)d mailbox', 'Renewal for %(quantity)d mailboxes', { - args: { quantity: licensed_quantity }, - count: licensed_quantity, - comment: '%(quantity)d is number of mailboxes renewed', - } ); - } - - if ( isUpgrade ) { - return translate( - 'Purchase of %(quantity)d additional mailbox', - 'Purchase of %(quantity)d additional mailboxes', - { - args: { quantity: new_quantity }, - count: new_quantity, - comment: '%(quantity)d is additional number of mailboxes purchased', - } - ); - } - - return translate( 'Purchase of %(quantity)d mailbox', 'Purchase of %(quantity)d mailboxes', { - args: { quantity: licensed_quantity }, - count: licensed_quantity, - comment: '%(quantity)d is number of mailboxes purchased', - } ); -} - -function renderDIFMTransactionQuantitySummary( - licensed_quantity: number, - translate: LocalizeProps[ 'translate' ] -) { - return translate( - 'One-time fee includes %(quantity)d page', - 'One-time fee includes %(quantity)d pages', - { - args: { quantity: licensed_quantity }, - count: licensed_quantity, - comment: '%(quantity)d is number of pages included in the purchase of the DIFM service', - } - ); -} - -function renderSpaceAddOnquantitySummary( - licensed_quantity: number, - isRenewal: boolean, - translate: LocalizeProps[ 'translate' ] -) { - if ( isRenewal ) { - return translate( 'Renewal for %(quantity)d GB', { - args: { quantity: licensed_quantity }, - comment: '%(quantity)d is number of GBs renewed', - } ); - } - - return translate( 'Purchase of %(quantity)d GB', { - args: { quantity: licensed_quantity }, - comment: '%(quantity)d is number of GBs purchased', - } ); -} - -export function renderDomainTransactionVolumeSummary( - { volume, product_slug, type }: BillingTransactionItem, - translate: LocalizeProps[ 'translate' ] -) { - if ( ! volume ) { - return null; - } - - const isRenewal = 'recurring' === type; - - volume = parseInt( String( volume ) ); - - if ( 'wp-domains' !== product_slug ) { - return null; - } - - if ( isRenewal ) { - return translate( - 'Domain renewed for %(quantity)d year', - 'Domain renewed for %(quantity)d years', - { - args: { quantity: volume }, - count: volume, - comment: '%(quantity)d is the number of years the domain has been renewed for', - } - ); - } - - return translate( - 'Domain registered for %(quantity)d year', - 'Domain registered for %(quantity)d years', - { - args: { quantity: volume }, - count: volume, - comment: '%(quantity)d is number of years the domain has been registered for', - } - ); -} - -export function renderTransactionQuantitySummary( - { licensed_quantity, new_quantity, type, wpcom_product_slug }: BillingTransactionItem, - translate: LocalizeProps[ 'translate' ] -) { - if ( ! licensed_quantity ) { - return null; - } - - licensed_quantity = parseInt( String( licensed_quantity ) ); - new_quantity = parseInt( String( new_quantity ) ); - const product = { product_slug: wpcom_product_slug }; - const isRenewal = 'recurring' === type; - const isUpgrade = 'new purchase' === type && new_quantity > 0; - - if ( isGoogleWorkspace( product ) || isTitanMail( product ) ) { - return renderTransactionQuantitySummaryForMailboxes( - licensed_quantity, - new_quantity, - isRenewal, - isUpgrade, - translate - ); - } - - if ( isDIFMProduct( product ) ) { - return renderDIFMTransactionQuantitySummary( licensed_quantity, translate ); - } - - if ( isTieredVolumeSpaceAddon( product ) ) { - return renderSpaceAddOnquantitySummary( licensed_quantity, isRenewal, translate ); - } - - if ( isRenewal ) { - return translate( 'Renewal for %(quantity)d item', 'Renewal for %(quantity)d items', { - args: { quantity: licensed_quantity }, - count: licensed_quantity, - comment: '%(quantity)d is number of items renewed', - } ); - } - - if ( isUpgrade ) { - return translate( - 'Purchase of %(quantity)d additional item', - 'Purchase of %(quantity)d additional items', - { - args: { quantity: new_quantity }, - count: new_quantity, - comment: '%(quantity)d is additional number of items purchased', - } - ); - } - - return translate( 'Purchase of %(quantity)d item', 'Purchase of %(quantity)d items', { - args: { quantity: licensed_quantity }, - count: licensed_quantity, - comment: '%(quantity)d is number of items purchased', - } ); -} - -export function getTransactionTermLabel( - transaction: BillingTransactionItem, - translate: LocalizeProps[ 'translate' ] -) { - switch ( transaction.months_per_renewal_interval ) { - case 1: - return translate( 'Monthly subscription' ); - case 12: - return translate( 'Annual subscription' ); - case 24: - return translate( 'Two year subscription' ); - default: - return getPlanTermLabel( transaction.wpcom_product_slug, translate ); - } -} diff --git a/client/me/purchases/billing-history-data-view/vat-vendor-details.tsx b/client/me/purchases/billing-history-data-view/vat-vendor-details.tsx deleted file mode 100644 index 7d502922a7759..0000000000000 --- a/client/me/purchases/billing-history-data-view/vat-vendor-details.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useTranslate } from 'i18n-calypso'; -import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; - -export function VatVendorDetails( { transaction }: { transaction: BillingTransaction } ) { - const translate = useTranslate(); - const vendorInfo = transaction.tax_vendor_info; - if ( ! vendorInfo ) { - return null; - } - - return ( -
  • - - { translate( 'Vendor %(taxName)s Details', { - args: { - taxName: Object.keys( vendorInfo.tax_name_and_vendor_id_array ).join( '/' ), - }, - comment: 'taxName is a localized tax, like VAT or GST', - } ) } - - - { vendorInfo.address.map( ( addressLine ) => ( -
    { addressLine }
    - ) ) } -
    - - { Object.entries( vendorInfo.tax_name_and_vendor_id_array ).map( ( [ taxName, taxID ] ) => ( -
    - { taxName } { taxID } -
    - ) ) } -
    -
  • - ); -} diff --git a/client/me/purchases/billing-history-data-view/billing-history-list.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx similarity index 99% rename from client/me/purchases/billing-history-data-view/billing-history-list.tsx rename to client/me/purchases/billing-history/billing-history-list-data-view.tsx index ba0cd62e06485..947ce3978f54e 100644 --- a/client/me/purchases/billing-history-data-view/billing-history-list.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -27,7 +27,7 @@ import type { Action } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import 'calypso/components/dataviews/style.scss'; -import './style.scss'; +import './style-data-view.scss'; const INITIAL_PAGE = 1; const ITEMS_PER_PAGE = 10; diff --git a/client/me/purchases/billing-history/main.tsx b/client/me/purchases/billing-history/main.tsx index 6c4d853936b74..7b7d09d2f3c3a 100644 --- a/client/me/purchases/billing-history/main.tsx +++ b/client/me/purchases/billing-history/main.tsx @@ -9,7 +9,7 @@ import NavigationHeader from 'calypso/components/navigation-header'; import { useGeoLocationQuery } from 'calypso/data/geo/use-geolocation-query'; import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; import BillingHistoryList from 'calypso/me/purchases/billing-history/billing-history-list'; -import BillingHistoryListDataView from 'calypso/me/purchases/billing-history-data-view/billing-history-list'; +import BillingHistoryListDataView from 'calypso/me/purchases/billing-history/billing-history-list-data-view'; import { vatDetails as vatDetailsPath, billingHistoryReceipt } from 'calypso/me/purchases/paths'; import PurchasesNavigation from 'calypso/me/purchases/purchases-navigation'; import titles from 'calypso/me/purchases/titles'; diff --git a/client/me/purchases/billing-history-data-view/style.scss b/client/me/purchases/billing-history/style-data-view.scss similarity index 100% rename from client/me/purchases/billing-history-data-view/style.scss rename to client/me/purchases/billing-history/style-data-view.scss From beedd4ba442981b964abb04f038276fb0478d0fb Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 3 Jan 2025 11:15:23 -0600 Subject: [PATCH 16/45] Refactors into hooks for cleaner organization --- .../billing-history-list-data-view.tsx | 430 +----------------- .../me/purchases/billing-history/constants.ts | 45 ++ .../billing-history/data-views-types.ts | 40 ++ .../billing-history/field-definitions.tsx | 175 +++++++ .../hooks/use-field-definitions.ts | 16 + .../billing-history/hooks/use-pagination.ts | 20 + .../hooks/use-receipt-actions.tsx | 45 ++ .../hooks/use-transactions-filtering.ts | 57 +++ .../hooks/use-transactions-sorting.ts | 36 ++ .../hooks/use-view-state-update.ts | 58 +++ client/me/purchases/billing-history/main.tsx | 6 +- 11 files changed, 511 insertions(+), 417 deletions(-) create mode 100644 client/me/purchases/billing-history/constants.ts create mode 100644 client/me/purchases/billing-history/data-views-types.ts create mode 100644 client/me/purchases/billing-history/field-definitions.tsx create mode 100644 client/me/purchases/billing-history/hooks/use-field-definitions.ts create mode 100644 client/me/purchases/billing-history/hooks/use-pagination.ts create mode 100644 client/me/purchases/billing-history/hooks/use-receipt-actions.tsx create mode 100644 client/me/purchases/billing-history/hooks/use-transactions-filtering.ts create mode 100644 client/me/purchases/billing-history/hooks/use-transactions-sorting.ts create mode 100644 client/me/purchases/billing-history/hooks/use-view-state-update.ts diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 947ce3978f54e..fe1edbb556c01 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -1,433 +1,39 @@ /* eslint-disable prettier/prettier */ -import pageRedirect from '@automattic/calypso-router'; -import { Gridicon } from '@automattic/components'; -import { DataViews, Operator } from '@wordpress/dataviews'; -import { useTranslate } from 'i18n-calypso'; -import { isEqual } from 'lodash'; -import moment from 'moment'; -import { useState, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { DataViews } from '@wordpress/dataviews'; +import { useSelector } from 'react-redux'; import { withLocalizedMoment } from 'calypso/components/localized-moment'; -import { capitalPDangit } from 'calypso/lib/formatting'; -import { recordGoogleEvent } from 'calypso/state/analytics/actions'; -import { sendBillingReceiptEmail } from 'calypso/state/billing-transactions/actions'; -import { - BillingTransaction, - BillingTransactionItem, -} from 'calypso/state/billing-transactions/types'; import getPastBillingTransactions from 'calypso/state/selectors/get-past-billing-transactions'; -import { - getTransactionTermLabel, - groupDomainProducts, - TransactionAmount, - renderTransactionQuantitySummary, -} from './utils'; -import type { IAppState } from 'calypso/state/types'; -import type { Action } from 'redux'; -import type { ThunkDispatch } from 'redux-thunk'; +import isRequestingBillingTransactions from 'calypso/state/selectors/is-requesting-billing-transactions'; +import { useFieldDefinitions } from './hooks/use-field-definitions'; +import { usePagination } from './hooks/use-pagination'; +import { useReceiptActions } from './hooks/use-receipt-actions'; +import { useTransactionsFiltering } from './hooks/use-transactions-filtering'; +import { useTransactionsSorting } from './hooks/use-transactions-sorting'; +import { useViewStateUpdate } from './hooks/use-view-state-update'; import 'calypso/components/dataviews/style.scss'; import './style-data-view.scss'; -const INITIAL_PAGE = 1; -const ITEMS_PER_PAGE = 10; - -type SortableField = 'date' | 'service' | 'type' | 'amount'; - -const SORT_DEFAULTS = { - FIELD: 'date' as SortableField, - DIRECTION: 'desc', -} as const; - -const TABLE_DEFAULTS = { - TYPE: 'table', - FIELDS: [ 'date', 'service', 'type', 'amount' ] as string[], -} as const; - -type ViewType = 'table'; -type SortDirection = 'asc' | 'desc'; - -interface ViewStateUpdate { - page?: number; - perPage?: number; - sort?: { - field: string; - direction: SortDirection; - }; - filters?: Array< { - field: string; - operator: Operator; - value: string | string[]; - } >; - search?: string; - fields?: string[]; -} - -interface ViewState { - type: ViewType; - search: string; - filters: Array< { - field: string; - operator: Operator; - value: string | string[]; - } >; - page: number; - perPage: number; - sort: { - field: SortableField; - direction: SortDirection; - }; - fields: string[]; - hiddenFields: string[]; -} - -const DATE_FORMATS = { - MONTH_YEAR: 'YYYY-MM', - MONTH_YEAR_LABEL: 'MMMM YYYY', - DISPLAY: 'll', -} as const; - -const TRANSACTION_TYPES = { - NEW_PURCHASE: { value: 'new purchase', label: 'New Purchase' }, - RENEWAL: { value: 'recurring', label: 'Renewal' }, -} as const; - -const recordClickEvent = ( eventAction: string ) => { - recordGoogleEvent( 'Me', eventAction ); -}; - -const getUniqueMonths = ( - transactions: BillingTransaction[] -): Array< { value: string; label: string } > => { - const uniqueMonths = new Set( - transactions.map( ( transaction ) => - moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) - ) - ); - - return Array.from( uniqueMonths ) - .sort() - .reverse() - .map( ( monthStr ) => ( { - value: monthStr, - label: moment( monthStr ).format( DATE_FORMATS.MONTH_YEAR_LABEL ), - } ) ); -}; - -const getUniqueServices = ( - transactions: BillingTransaction[] -): Array< { value: string; label: string } > => { - const uniqueServices = new Set( transactions.map( ( transaction ) => transaction.service ) ); - - return Array.from( uniqueServices ) - .sort() - .map( ( service ) => ( { - value: service, - label: service, - } ) ); -}; - -const serviceNameDescription = ( - transaction: BillingTransactionItem, - translate: ReturnType< typeof useTranslate > -) => { - const plan = capitalPDangit( transaction.variation ); - const termLabel = getTransactionTermLabel( transaction, translate ); - return ( -
    - { plan } - { transaction.domain && { transaction.domain } } - { termLabel && { termLabel } } - { transaction.licensed_quantity && ( - { renderTransactionQuantitySummary( transaction, translate ) } - ) } -
    - ); -}; - -const serviceName = ( - transaction: BillingTransaction, - translate: ReturnType< typeof useTranslate > -) => { - const [ transactionItem, ...moreTransactionItems ] = groupDomainProducts( - transaction.items, - translate - ); - - if ( moreTransactionItems.length > 0 ) { - return { translate( 'Multiple items' ) }; - } - - if ( transactionItem.product === transactionItem.variation ) { - return transactionItem.product; - } - - return serviceNameDescription( transactionItem, translate ); -}; - export interface BillingHistoryListProps { getReceiptUrlFor: ( receiptId: string ) => string; } -interface WithMoment { - moment: typeof moment; -} - -const usePagination = ( items: BillingTransaction[], page: number, perPage: number ) => { - return useMemo( () => { - const startIndex = ( page - 1 ) * perPage; - return { - paginatedItems: items.slice( startIndex, startIndex + perPage ), - totalPages: Math.ceil( items.length / perPage ), - totalItems: items.length, - }; - }, [ items, page, perPage ] ); -}; - -const BillingHistoryListDataView: React.FC< BillingHistoryListProps & WithMoment > = ( { +const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { getReceiptUrlFor, - moment, } ) => { - const translate = useTranslate(); - const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); const transactions = useSelector( getPastBillingTransactions ); + const isLoading = useSelector( isRequestingBillingTransactions ); + const { view, updateView } = useViewStateUpdate(); + const actions = useReceiptActions( getReceiptUrlFor ); - const [ view, setView ] = useState< ViewState >( { - type: TABLE_DEFAULTS.TYPE, - search: '', - filters: [], - page: INITIAL_PAGE, - perPage: ITEMS_PER_PAGE, - sort: { - field: SORT_DEFAULTS.FIELD, - direction: SORT_DEFAULTS.DIRECTION, - }, - fields: [ ...TABLE_DEFAULTS.FIELDS ], - hiddenFields: [], - } ); - - const filteredTransactions = ( transactions ?? [] ).filter( ( transaction ) => { - if ( view.search ) { - const searchTerm = view.search.toLowerCase(); - const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); - const searchableFields = [ - transaction.service, - transactionItem.product, - transactionItem.variation, - transactionItem.domain, - moment( transaction.date ).format( DATE_FORMATS.DISPLAY ), - transaction.amount, - ]; - - if ( - ! searchableFields.some( - ( field ) => field && field.toString().toLowerCase().includes( searchTerm ) - ) - ) { - return false; - } - } - - if ( view.filters.length === 0 ) { - return true; - } - - return view.filters.every( ( filter ) => { - if ( filter.field === 'service' && filter.value ) { - return transaction.service === filter.value; - } - if ( filter.field === 'type' && filter.value ) { - const [ firstItem ] = groupDomainProducts( transaction.items, translate ); - return firstItem.type === filter.value; - } - if ( filter.field === 'date' && filter.value ) { - return moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) === filter.value; - } - return true; - } ); - } ); - - const sortedTransactions = [ ...filteredTransactions ].sort( ( a, b ) => { - let comparison = 0; - const sortField = view.sort.field as SortableField; - - switch ( sortField ) { - case 'date': - comparison = new Date( a.date ).getTime() - new Date( b.date ).getTime(); - break; - case 'service': { - const aService = a.items.length > 0 ? a.items[ 0 ].variation : a.service; - const bService = b.items.length > 0 ? b.items[ 0 ].variation : b.service; - comparison = ( aService || '' ).localeCompare( bService || '' ); - break; - } - case 'amount': - comparison = a.amount_integer - b.amount_integer; - break; - default: - return 0; - } - return view.sort.direction === 'desc' ? -comparison : comparison; - } ); - + const filteredTransactions = useTransactionsFiltering( transactions, view ); + const sortedTransactions = useTransactionsSorting( filteredTransactions, view ); const { paginatedItems, totalPages, totalItems } = usePagination( sortedTransactions, view.page, view.perPage ); - - const onChangeView = ( newView: ViewStateUpdate ) => { - setView( ( currentView ) => { - const updatedView = { ...currentView }; - - if ( newView.page !== undefined && newView.page !== currentView.page ) { - updatedView.page = newView.page; - } - - if ( newView.perPage && newView.perPage !== currentView.perPage ) { - updatedView.perPage = newView.perPage; - updatedView.page = 1; - } - - if ( newView.sort && ! isEqual( newView.sort, currentView.sort ) ) { - updatedView.sort = { - field: newView.sort.field as SortableField, - direction: newView.sort.direction, - }; - } - - if ( newView.filters && ! isEqual( newView.filters, currentView.filters ) ) { - updatedView.filters = newView.filters; - updatedView.page = 1; - } - - if ( newView.search !== undefined ) { - updatedView.search = newView.search; - } - - if ( newView.fields !== undefined ) { - updatedView.fields = newView.fields; - } - - return updatedView; - } ); - }; - - const fields = useMemo( () => { - const fieldDefinitions = { - date: { - id: 'date', - label: 'Date', - type: 'text' as const, - elements: getUniqueMonths( transactions ?? [] ), - enableGlobalSearch: true, - enableHiding: true, - enableSorting: true, - isHidden: view.hiddenFields.includes( 'date' ), - filterBy: { - operators: [ 'is' as Operator ], - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - return moment( item.date ).format( DATE_FORMATS.MONTH_YEAR ); - }, - render: ( { item }: { item: BillingTransaction } ) => { - return ; - }, - }, - service: { - id: 'service', - label: 'App', - type: 'text' as const, - elements: getUniqueServices( transactions ?? [] ), - enableGlobalSearch: true, - enableHiding: true, - enableSorting: true, - isHidden: view.hiddenFields.includes( 'service' ), - filterBy: { - operators: [ 'is' as Operator ], - }, - render: ( { item }: { item: BillingTransaction } ) => { - return
    { serviceName( item, translate ) }
    ; - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - if ( transactionItem.product === transactionItem.variation ) { - return transactionItem.product; - } - return capitalPDangit( transactionItem.variation ); - }, - }, - type: { - id: 'type', - label: 'Type', - type: 'text' as const, - elements: [ TRANSACTION_TYPES.NEW_PURCHASE, TRANSACTION_TYPES.RENEWAL ], - enableGlobalSearch: true, - enableHiding: true, - enableSorting: true, - isHidden: view.hiddenFields.includes( 'type' ), - filterBy: { - operators: [ 'is' as Operator ], - }, - render: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - return ( -
    { transactionItem.type_localized || capitalPDangit( transactionItem.type ) }
    - ); - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - return transactionItem.type; - }, - }, - amount: { - id: 'amount', - label: 'Amount', - type: 'text' as const, - enableGlobalSearch: true, - enableHiding: true, - enableSorting: true, - isHidden: view.hiddenFields.includes( 'amount' ), - getValue: ( { item }: { item: BillingTransaction } ) => { - return item.amount_integer; - }, - render: ( { item }: { item: BillingTransaction } ) => { - return ; - }, - }, - }; - - return view.fields.map( - ( fieldId ) => fieldDefinitions[ fieldId as keyof typeof fieldDefinitions ] - ); - }, [ transactions, view.hiddenFields, view.fields, translate, moment ] ); - - const actions = useMemo( - () => [ - { - id: 'view-receipt', - label: 'View receipt', - isPrimary: true, - icon: , - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - pageRedirect.redirect( getReceiptUrlFor( item.id ) ); - }, - }, - { - id: 'email-receipt', - label: 'Email receipt', - isPrimary: true, - icon: , - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - recordClickEvent( 'Email Receipt in Billing History' ); - dispatch( sendBillingReceiptEmail( item.id ) ); - }, - }, - ], - [ dispatch, getReceiptUrlFor ] - ); + const fields = useFieldDefinitions( transactions, view ); return (
    @@ -442,10 +48,10 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps & WithMoment view={ view } search searchLabel="Search receipts" - onChangeView={ onChangeView } + onChangeView={ updateView } defaultLayouts={ { table: {} } } actions={ actions } - isLoading={ false } + isLoading={ isLoading } />
    diff --git a/client/me/purchases/billing-history/constants.ts b/client/me/purchases/billing-history/constants.ts new file mode 100644 index 0000000000000..d9f66e776fc55 --- /dev/null +++ b/client/me/purchases/billing-history/constants.ts @@ -0,0 +1,45 @@ +import type { ViewState } from './data-views-types'; + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PER_PAGE = 10; + +export const DATE_FORMATS = { + MONTH_YEAR: 'YYYY-MM', + MONTH_YEAR_LABEL: 'MMMM YYYY', + DISPLAY: 'll', +} as const; + +export const TRANSACTION_TYPES = { + NEW_PURCHASE: { value: 'new purchase', label: 'New Purchase' }, + RENEWAL: { value: 'recurring', label: 'Renewal' }, +} as const; + +export const defaultDataViewsState: ViewState = { + type: 'table', + search: '', + filters: [], + page: DEFAULT_PAGE, + perPage: DEFAULT_PER_PAGE, + sort: { + field: 'date', + direction: 'desc', + }, + fields: [ 'date', 'service', 'type', 'amount' ], + hiddenFields: [], + layout: { + styles: { + date: { + width: '15%', + }, + service: { + width: '45%', + }, + type: { + width: '20%', + }, + amount: { + width: '20%', + }, + }, + }, +}; diff --git a/client/me/purchases/billing-history/data-views-types.ts b/client/me/purchases/billing-history/data-views-types.ts new file mode 100644 index 0000000000000..a31ef86462c26 --- /dev/null +++ b/client/me/purchases/billing-history/data-views-types.ts @@ -0,0 +1,40 @@ +import { Operator } from '@wordpress/dataviews'; + +export type SortableField = 'date' | 'service' | 'type' | 'amount'; +export type ViewType = 'table'; +export type SortDirection = 'asc' | 'desc'; + +export interface Filter { + field: string; + operator: Operator; + value: string | string[]; +} + +export interface ViewStateUpdate { + page?: number; + perPage?: number; + sort?: { + field: string; + direction: SortDirection; + }; + filters?: Filter[]; + search?: string; + fields?: string[]; +} + +export interface ViewState { + type: ViewType; + search: string; + filters: Filter[]; + page: number; + perPage: number; + sort: { + field: SortableField; + direction: SortDirection; + }; + fields: string[]; + hiddenFields: string[]; + layout?: { + styles?: Record< string, { width: string } >; + }; +} diff --git a/client/me/purchases/billing-history/field-definitions.tsx b/client/me/purchases/billing-history/field-definitions.tsx new file mode 100644 index 0000000000000..8c54c8c0aac50 --- /dev/null +++ b/client/me/purchases/billing-history/field-definitions.tsx @@ -0,0 +1,175 @@ +import { type Operator } from '@wordpress/dataviews'; +import { useTranslate } from 'i18n-calypso'; +import moment from 'moment'; +import { capitalPDangit } from 'calypso/lib/formatting'; +import { DATE_FORMATS, TRANSACTION_TYPES } from './constants'; +import { + getTransactionTermLabel, + groupDomainProducts, + TransactionAmount, + renderTransactionQuantitySummary, +} from './utils'; +import type { + BillingTransaction, + BillingTransactionItem, +} from 'calypso/state/billing-transactions/types'; + +const serviceNameDescription = ( + transaction: BillingTransactionItem, + translate: ReturnType< typeof useTranslate > +) => { + const plan = capitalPDangit( transaction.variation ); + const termLabel = getTransactionTermLabel( transaction, translate ); + return ( +
    + { plan } + { transaction.domain && { transaction.domain } } + { termLabel && { termLabel } } + { transaction.licensed_quantity && ( + { renderTransactionQuantitySummary( transaction, translate ) } + ) } +
    + ); +}; + +const serviceName = ( + transaction: BillingTransaction, + translate: ReturnType< typeof useTranslate > +) => { + const [ transactionItem, ...moreTransactionItems ] = groupDomainProducts( + transaction.items, + translate + ); + + if ( moreTransactionItems.length > 0 ) { + return { translate( 'Multiple items' ) }; + } + + if ( transactionItem.product === transactionItem.variation ) { + return transactionItem.product; + } + + return serviceNameDescription( transactionItem, translate ); +}; + +const getUniqueMonths = ( + transactions: BillingTransaction[] +): Array< { value: string; label: string } > => { + const uniqueMonths = new Set( + transactions.map( ( transaction ) => + moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) + ) + ); + + return Array.from( uniqueMonths ) + .sort() + .reverse() + .map( ( monthStr ) => ( { + value: monthStr, + label: moment( monthStr ).format( DATE_FORMATS.MONTH_YEAR_LABEL ), + } ) ); +}; + +const getUniqueServices = ( + transactions: BillingTransaction[] +): Array< { value: string; label: string } > => { + const uniqueServices = new Set( transactions.map( ( transaction ) => transaction.service ) ); + + return Array.from( uniqueServices ) + .sort() + .map( ( service ) => ( { + value: service, + label: service, + } ) ); +}; + +export const getFieldDefinitions = ( + transactions: BillingTransaction[] | null, + hiddenFields: string[], + translate: ReturnType< typeof useTranslate > +) => ( { + date: { + id: 'date', + label: 'Date', + type: 'text' as const, + width: '15%', + elements: getUniqueMonths( transactions ?? [] ), + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: hiddenFields.includes( 'date' ), + filterBy: { + operators: [ 'is' as Operator ], + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + return moment( item.date ).format( DATE_FORMATS.MONTH_YEAR ); + }, + render: ( { item }: { item: BillingTransaction } ) => { + return ; + }, + }, + service: { + id: 'service', + label: 'App', + type: 'text' as const, + width: '45%', + elements: getUniqueServices( transactions ?? [] ), + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: hiddenFields.includes( 'service' ), + filterBy: { + operators: [ 'is' as Operator ], + }, + render: ( { item }: { item: BillingTransaction } ) => { + return
    { serviceName( item, translate ) }
    ; + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + if ( transactionItem.product === transactionItem.variation ) { + return transactionItem.product; + } + return capitalPDangit( transactionItem.variation ); + }, + }, + type: { + id: 'type', + label: 'Type', + type: 'text' as const, + width: '20%', + elements: [ TRANSACTION_TYPES.NEW_PURCHASE, TRANSACTION_TYPES.RENEWAL ], + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: hiddenFields.includes( 'type' ), + filterBy: { + operators: [ 'is' as Operator ], + }, + render: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return ( +
    { transactionItem.type_localized || capitalPDangit( transactionItem.type ) }
    + ); + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return transactionItem.type; + }, + }, + amount: { + id: 'amount', + label: 'Amount', + type: 'text' as const, + width: '20%', + enableGlobalSearch: true, + enableHiding: true, + enableSorting: true, + isHidden: hiddenFields.includes( 'amount' ), + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.amount_integer; + }, + render: ( { item }: { item: BillingTransaction } ) => { + return ; + }, + }, +} ); diff --git a/client/me/purchases/billing-history/hooks/use-field-definitions.ts b/client/me/purchases/billing-history/hooks/use-field-definitions.ts new file mode 100644 index 0000000000000..db6ebcf7724e5 --- /dev/null +++ b/client/me/purchases/billing-history/hooks/use-field-definitions.ts @@ -0,0 +1,16 @@ +import { useTranslate } from 'i18n-calypso'; +import { useMemo } from 'react'; +import { getFieldDefinitions } from '../field-definitions'; +import type { ViewState } from '../data-views-types'; +import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; + +export function useFieldDefinitions( transactions: BillingTransaction[] | null, view: ViewState ) { + const translate = useTranslate(); + + return useMemo( () => { + const fieldDefinitions = getFieldDefinitions( transactions, view.hiddenFields, translate ); + return view.fields.map( + ( fieldId ) => fieldDefinitions[ fieldId as keyof typeof fieldDefinitions ] + ); + }, [ transactions, view.hiddenFields, view.fields, translate ] ); +} diff --git a/client/me/purchases/billing-history/hooks/use-pagination.ts b/client/me/purchases/billing-history/hooks/use-pagination.ts new file mode 100644 index 0000000000000..fba00fbbe47b2 --- /dev/null +++ b/client/me/purchases/billing-history/hooks/use-pagination.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; + +export function usePagination( items: BillingTransaction[], page: number, perPage: number ) { + return useMemo( () => { + const startIndex = ( page - 1 ) * perPage; + const paginatedItems = items.slice( startIndex, startIndex + perPage ); + const totalItems = items.length; + const totalPages = Math.ceil( totalItems / perPage ); + + return { + paginatedItems, + totalPages, + totalItems, + currentPage: page, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }; + }, [ items, page, perPage ] ); +} diff --git a/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx b/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx new file mode 100644 index 0000000000000..c0e80a9ee9e19 --- /dev/null +++ b/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx @@ -0,0 +1,45 @@ +import pageRedirect from '@automattic/calypso-router'; +import { Gridicon } from '@automattic/components'; +import { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { recordGoogleEvent } from 'calypso/state/analytics/actions'; +import { sendBillingReceiptEmail } from 'calypso/state/billing-transactions/actions'; +import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; +import type { IAppState } from 'calypso/state/types'; +import type { Action } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; + +const recordClickEvent = ( eventAction: string ) => { + recordGoogleEvent( 'Me', eventAction ); +}; + +export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => string ) { + const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); + + return useMemo( + () => [ + { + id: 'view-receipt', + label: 'View receipt', + isPrimary: true, + icon: , + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + pageRedirect.redirect( getReceiptUrlFor( item.id ) ); + }, + }, + { + id: 'email-receipt', + label: 'Email receipt', + isPrimary: true, + icon: , + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + recordClickEvent( 'Email Receipt in Billing History' ); + dispatch( sendBillingReceiptEmail( item.id ) ); + }, + }, + ], + [ dispatch, getReceiptUrlFor ] + ); +} diff --git a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts new file mode 100644 index 0000000000000..1a8e1979d17a7 --- /dev/null +++ b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts @@ -0,0 +1,57 @@ +import { useTranslate } from 'i18n-calypso'; +import moment from 'moment'; +import { useMemo } from 'react'; +import { BillingTransaction } from 'calypso/state/billing-transactions/types'; +import { DATE_FORMATS } from '../constants'; +import { groupDomainProducts } from '../utils'; +import type { ViewState, Filter } from '../data-views-types'; + +export function useTransactionsFiltering( + transactions: BillingTransaction[] | null, + view: ViewState +) { + const translate = useTranslate(); + + return useMemo( () => { + return ( transactions ?? [] ).filter( ( transaction ) => { + if ( view.search ) { + const searchTerm = view.search.toLowerCase(); + const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); + const searchableFields = [ + transaction.service, + transactionItem.product, + transactionItem.variation, + transactionItem.domain, + moment( transaction.date ).format( DATE_FORMATS.DISPLAY ), + transaction.amount, + ]; + + if ( + ! searchableFields.some( + ( field ) => field && field.toString().toLowerCase().includes( searchTerm ) + ) + ) { + return false; + } + } + + if ( view.filters.length === 0 ) { + return true; + } + + return view.filters.every( ( filter: Filter ) => { + if ( filter.field === 'service' && filter.value ) { + return transaction.service === filter.value; + } + if ( filter.field === 'type' && filter.value ) { + const [ firstItem ] = groupDomainProducts( transaction.items, translate ); + return firstItem.type === filter.value; + } + if ( filter.field === 'date' && filter.value ) { + return moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) === filter.value; + } + return true; + } ); + } ); + }, [ transactions, view.search, view.filters, translate ] ); +} diff --git a/client/me/purchases/billing-history/hooks/use-transactions-sorting.ts b/client/me/purchases/billing-history/hooks/use-transactions-sorting.ts new file mode 100644 index 0000000000000..f1201b1ef447d --- /dev/null +++ b/client/me/purchases/billing-history/hooks/use-transactions-sorting.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import { BillingTransaction } from 'calypso/state/billing-transactions/types'; +import type { ViewState } from '../data-views-types'; + +export function useTransactionsSorting( transactions: BillingTransaction[], view: ViewState ) { + return useMemo( () => { + return [ ...transactions ].sort( ( a, b ) => { + let comparison = 0; + const sortField = view.sort.field; + + switch ( sortField ) { + case 'date': + comparison = new Date( a.date ).getTime() - new Date( b.date ).getTime(); + break; + case 'service': { + const aService = a.items.length > 0 ? a.items[ 0 ].variation : a.service; + const bService = b.items.length > 0 ? b.items[ 0 ].variation : b.service; + comparison = ( aService || '' ).localeCompare( bService || '' ); + break; + } + case 'type': { + const aType = a.items.length > 0 ? a.items[ 0 ].type : ''; + const bType = b.items.length > 0 ? b.items[ 0 ].type : ''; + comparison = ( aType || '' ).localeCompare( bType || '' ); + break; + } + case 'amount': + comparison = a.amount_integer - b.amount_integer; + break; + default: + return 0; + } + return view.sort.direction === 'desc' ? -comparison : comparison; + } ); + }, [ transactions, view.sort ] ); +} diff --git a/client/me/purchases/billing-history/hooks/use-view-state-update.ts b/client/me/purchases/billing-history/hooks/use-view-state-update.ts new file mode 100644 index 0000000000000..afcb0e89632ec --- /dev/null +++ b/client/me/purchases/billing-history/hooks/use-view-state-update.ts @@ -0,0 +1,58 @@ +import { isEqual } from 'lodash'; +import { useState } from 'react'; +import { defaultDataViewsState } from '../constants'; +import type { ViewState, ViewStateUpdate, SortableField } from '../data-views-types'; + +export function useViewStateUpdate() { + const [ view, setView ] = useState< ViewState >( defaultDataViewsState ); + + const updateView = ( newView: ViewStateUpdate ) => { + setView( ( currentView ) => { + const updatedView = { ...currentView } as ViewState; + + if ( newView.page !== undefined ) { + updatedView.page = newView.page; + } + + if ( newView.perPage !== undefined && newView.perPage !== currentView.perPage ) { + updatedView.perPage = newView.perPage; + updatedView.page = 1; // Reset to first page when changing items per page + } + + if ( newView.sort && ! isEqual( newView.sort, currentView.sort ) ) { + updatedView.sort = { + field: newView.sort.field as SortableField, + direction: newView.sort.direction, + }; + if ( newView.page === undefined ) { + updatedView.page = 1; // Reset to first page when changing sort + } + } + + if ( newView.filters && ! isEqual( newView.filters, currentView.filters ) ) { + updatedView.filters = newView.filters; + if ( newView.page === undefined ) { + updatedView.page = 1; // Reset to first page when changing filters + } + } + + if ( newView.search !== undefined && newView.search !== currentView.search ) { + updatedView.search = newView.search; + if ( newView.page === undefined ) { + updatedView.page = 1; // Reset to first page when changing search + } + } + + if ( newView.fields !== undefined ) { + updatedView.fields = newView.fields; + } + + return updatedView; + } ); + }; + + return { + view, + updateView, + }; +} diff --git a/client/me/purchases/billing-history/main.tsx b/client/me/purchases/billing-history/main.tsx index 7b7d09d2f3c3a..ee9c51cc741d6 100644 --- a/client/me/purchases/billing-history/main.tsx +++ b/client/me/purchases/billing-history/main.tsx @@ -30,11 +30,7 @@ export function BillingHistoryContent( { return ( { useDataViewBillingHistoryList ? ( - + ) : ( ) } From 48c2525007bdba5bb5e1b8d73f25255c48d219cf Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 3 Jan 2025 11:27:03 -0600 Subject: [PATCH 17/45] Adds scroll to top after page changes --- .../hooks/use-view-state-update.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-view-state-update.ts b/client/me/purchases/billing-history/hooks/use-view-state-update.ts index afcb0e89632ec..f3fd9da2280e2 100644 --- a/client/me/purchases/billing-history/hooks/use-view-state-update.ts +++ b/client/me/purchases/billing-history/hooks/use-view-state-update.ts @@ -1,22 +1,28 @@ import { isEqual } from 'lodash'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { defaultDataViewsState } from '../constants'; import type { ViewState, ViewStateUpdate, SortableField } from '../data-views-types'; export function useViewStateUpdate() { const [ view, setView ] = useState< ViewState >( defaultDataViewsState ); + const scrollToTop = useCallback( () => { + window.scrollTo( { top: 0, behavior: 'smooth' } ); + }, [] ); + const updateView = ( newView: ViewStateUpdate ) => { setView( ( currentView ) => { const updatedView = { ...currentView } as ViewState; if ( newView.page !== undefined ) { updatedView.page = newView.page; + scrollToTop(); } if ( newView.perPage !== undefined && newView.perPage !== currentView.perPage ) { updatedView.perPage = newView.perPage; - updatedView.page = 1; // Reset to first page when changing items per page + updatedView.page = 1; + scrollToTop(); } if ( newView.sort && ! isEqual( newView.sort, currentView.sort ) ) { @@ -25,21 +31,24 @@ export function useViewStateUpdate() { direction: newView.sort.direction, }; if ( newView.page === undefined ) { - updatedView.page = 1; // Reset to first page when changing sort + updatedView.page = 1; + scrollToTop(); } } if ( newView.filters && ! isEqual( newView.filters, currentView.filters ) ) { updatedView.filters = newView.filters; if ( newView.page === undefined ) { - updatedView.page = 1; // Reset to first page when changing filters + updatedView.page = 1; + scrollToTop(); } } if ( newView.search !== undefined && newView.search !== currentView.search ) { updatedView.search = newView.search; if ( newView.page === undefined ) { - updatedView.page = 1; // Reset to first page when changing search + updatedView.page = 1; + scrollToTop(); } } From 03daefb17344c9d8c65e217c1b3323b84581680d Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 3 Jan 2025 12:16:28 -0600 Subject: [PATCH 18/45] Fixes deprecated param --- .../billing-history/hooks/use-transactions-filtering.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts index 1a8e1979d17a7..b95ef57bed0af 100644 --- a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts +++ b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts @@ -19,11 +19,11 @@ export function useTransactionsFiltering( const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); const searchableFields = [ transaction.service, + transaction.amount_integer, transactionItem.product, transactionItem.variation, transactionItem.domain, moment( transaction.date ).format( DATE_FORMATS.DISPLAY ), - transaction.amount, ]; if ( From 66d418bd53a92bf913952b8c2e71132fbdcb78e4 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Fri, 3 Jan 2025 14:44:36 -0600 Subject: [PATCH 19/45] Adds missing translation support and dynamic order types for filtering --- .../billing-history-list-data-view.tsx | 4 +- .../me/purchases/billing-history/constants.ts | 5 --- .../billing-history/field-definitions.tsx | 37 ++++++++++++++----- .../hooks/use-receipt-actions.tsx | 8 ++-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index fe1edbb556c01..041ea5d3f5e8f 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -1,5 +1,6 @@ /* eslint-disable prettier/prettier */ import { DataViews } from '@wordpress/dataviews'; +import { useTranslate } from 'i18n-calypso'; import { useSelector } from 'react-redux'; import { withLocalizedMoment } from 'calypso/components/localized-moment'; import getPastBillingTransactions from 'calypso/state/selectors/get-past-billing-transactions'; @@ -34,6 +35,7 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { view.perPage ); const fields = useFieldDefinitions( transactions, view ); + const translate = useTranslate(); return (
    @@ -47,7 +49,7 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { fields={ fields } view={ view } search - searchLabel="Search receipts" + searchLabel={ translate( 'Search receipts' ) } onChangeView={ updateView } defaultLayouts={ { table: {} } } actions={ actions } diff --git a/client/me/purchases/billing-history/constants.ts b/client/me/purchases/billing-history/constants.ts index d9f66e776fc55..b75f25f488b85 100644 --- a/client/me/purchases/billing-history/constants.ts +++ b/client/me/purchases/billing-history/constants.ts @@ -9,11 +9,6 @@ export const DATE_FORMATS = { DISPLAY: 'll', } as const; -export const TRANSACTION_TYPES = { - NEW_PURCHASE: { value: 'new purchase', label: 'New Purchase' }, - RENEWAL: { value: 'recurring', label: 'Renewal' }, -} as const; - export const defaultDataViewsState: ViewState = { type: 'table', search: '', diff --git a/client/me/purchases/billing-history/field-definitions.tsx b/client/me/purchases/billing-history/field-definitions.tsx index 8c54c8c0aac50..6c45eea04f722 100644 --- a/client/me/purchases/billing-history/field-definitions.tsx +++ b/client/me/purchases/billing-history/field-definitions.tsx @@ -2,7 +2,7 @@ import { type Operator } from '@wordpress/dataviews'; import { useTranslate } from 'i18n-calypso'; import moment from 'moment'; import { capitalPDangit } from 'calypso/lib/formatting'; -import { DATE_FORMATS, TRANSACTION_TYPES } from './constants'; +import { DATE_FORMATS } from './constants'; import { getTransactionTermLabel, groupDomainProducts, @@ -83,6 +83,27 @@ const getUniqueServices = ( } ) ); }; +const getUniqueTransactionTypes = ( + transactions: BillingTransaction[] +): Array< { value: string; label: string } > => { + const typeMap = new Map< string, string >(); + + transactions + .flatMap( ( transaction ) => transaction.items ) + .forEach( ( item ) => { + if ( item.type && ! typeMap.has( item.type ) ) { + typeMap.set( item.type, item.type_localized || item.type ); + } + } ); + + return Array.from( typeMap.entries() ) + .sort( ( [ a ], [ b ] ) => a.localeCompare( b ) ) + .map( ( [ value, label ] ) => ( { + value, + label, + } ) ); +}; + export const getFieldDefinitions = ( transactions: BillingTransaction[] | null, hiddenFields: string[], @@ -90,7 +111,7 @@ export const getFieldDefinitions = ( ) => ( { date: { id: 'date', - label: 'Date', + label: translate( 'Date' ), type: 'text' as const, width: '15%', elements: getUniqueMonths( transactions ?? [] ), @@ -110,7 +131,7 @@ export const getFieldDefinitions = ( }, service: { id: 'service', - label: 'App', + label: translate( 'App' ), type: 'text' as const, width: '45%', elements: getUniqueServices( transactions ?? [] ), @@ -134,10 +155,10 @@ export const getFieldDefinitions = ( }, type: { id: 'type', - label: 'Type', + label: translate( 'Type' ), type: 'text' as const, width: '20%', - elements: [ TRANSACTION_TYPES.NEW_PURCHASE, TRANSACTION_TYPES.RENEWAL ], + elements: getUniqueTransactionTypes( transactions ?? [] ), enableGlobalSearch: true, enableHiding: true, enableSorting: true, @@ -147,9 +168,7 @@ export const getFieldDefinitions = ( }, render: ( { item }: { item: BillingTransaction } ) => { const [ transactionItem ] = groupDomainProducts( item.items, translate ); - return ( -
    { transactionItem.type_localized || capitalPDangit( transactionItem.type ) }
    - ); + return
    { transactionItem.type_localized || transactionItem.type }
    ; }, getValue: ( { item }: { item: BillingTransaction } ) => { const [ transactionItem ] = groupDomainProducts( item.items, translate ); @@ -158,7 +177,7 @@ export const getFieldDefinitions = ( }, amount: { id: 'amount', - label: 'Amount', + label: translate( 'Amount' ), type: 'text' as const, width: '20%', enableGlobalSearch: true, diff --git a/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx b/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx index c0e80a9ee9e19..106c37d115f3e 100644 --- a/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx +++ b/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx @@ -1,5 +1,6 @@ import pageRedirect from '@automattic/calypso-router'; import { Gridicon } from '@automattic/components'; +import { useTranslate } from 'i18n-calypso'; import { useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { recordGoogleEvent } from 'calypso/state/analytics/actions'; @@ -15,12 +16,13 @@ const recordClickEvent = ( eventAction: string ) => { export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => string ) { const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); + const translate = useTranslate(); return useMemo( () => [ { id: 'view-receipt', - label: 'View receipt', + label: translate( 'View receipt' ), isPrimary: true, icon: , callback: ( items: BillingTransaction[] ) => { @@ -30,7 +32,7 @@ export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => st }, { id: 'email-receipt', - label: 'Email receipt', + label: translate( 'Email receipt' ), isPrimary: true, icon: , callback: ( items: BillingTransaction[] ) => { @@ -40,6 +42,6 @@ export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => st }, }, ], - [ dispatch, getReceiptUrlFor ] + [ dispatch, getReceiptUrlFor, translate ] ); } From 7e91069c66e56a95a057be9b120dfc9d86c74e3c Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Sun, 5 Jan 2025 21:46:44 -0600 Subject: [PATCH 20/45] Refines action hooks --- .../billing-history-list-data-view.tsx | 8 +++++++- .../hooks/use-receipt-actions.tsx | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 041ea5d3f5e8f..78f02da6996f7 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -1,4 +1,5 @@ /* eslint-disable prettier/prettier */ +import { Gridicon } from '@automattic/components'; import { DataViews } from '@wordpress/dataviews'; import { useTranslate } from 'i18n-calypso'; import { useSelector } from 'react-redux'; @@ -25,7 +26,12 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { const transactions = useSelector( getPastBillingTransactions ); const isLoading = useSelector( isRequestingBillingTransactions ); const { view, updateView } = useViewStateUpdate(); - const actions = useReceiptActions( getReceiptUrlFor ); + const receiptActions = useReceiptActions( getReceiptUrlFor ); + + const actions = receiptActions.map( ( action ) => ( { + ...action, + icon: , + } ) ); const filteredTransactions = useTransactionsFiltering( transactions, view ); const sortedTransactions = useTransactionsSorting( filteredTransactions, view ); diff --git a/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx b/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx index 106c37d115f3e..97c5bb26d52f4 100644 --- a/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx +++ b/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx @@ -1,5 +1,4 @@ import pageRedirect from '@automattic/calypso-router'; -import { Gridicon } from '@automattic/components'; import { useTranslate } from 'i18n-calypso'; import { useMemo } from 'react'; import { useDispatch } from 'react-redux'; @@ -14,7 +13,17 @@ const recordClickEvent = ( eventAction: string ) => { recordGoogleEvent( 'Me', eventAction ); }; -export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => string ) { +export type ReceiptAction = { + id: 'view-receipt' | 'email-receipt'; + label: string; + isPrimary: boolean; + iconName: string; + callback: ( items: BillingTransaction[] ) => void; +}; + +export function useReceiptActions( + getReceiptUrlFor: ( receiptId: string ) => string +): ReceiptAction[] { const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); const translate = useTranslate(); @@ -24,7 +33,7 @@ export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => st id: 'view-receipt', label: translate( 'View receipt' ), isPrimary: true, - icon: , + iconName: 'pages', callback: ( items: BillingTransaction[] ) => { const item = items[ 0 ]; pageRedirect.redirect( getReceiptUrlFor( item.id ) ); @@ -34,7 +43,7 @@ export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => st id: 'email-receipt', label: translate( 'Email receipt' ), isPrimary: true, - icon: , + iconName: 'mail', callback: ( items: BillingTransaction[] ) => { const item = items[ 0 ]; recordClickEvent( 'Email Receipt in Billing History' ); From 04c451938fdc07d1dc9e2e10293ecd94ad0c1608 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 6 Jan 2025 10:34:38 -0600 Subject: [PATCH 21/45] Renaming --- .../billing-history-list-data-view.tsx | 1 - .../hooks/use-receipt-actions.tsx | 56 ------------------- 2 files changed, 57 deletions(-) delete mode 100644 client/me/purchases/billing-history/hooks/use-receipt-actions.tsx diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 78f02da6996f7..695495517da5b 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ import { Gridicon } from '@automattic/components'; import { DataViews } from '@wordpress/dataviews'; import { useTranslate } from 'i18n-calypso'; diff --git a/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx b/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx deleted file mode 100644 index 97c5bb26d52f4..0000000000000 --- a/client/me/purchases/billing-history/hooks/use-receipt-actions.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import pageRedirect from '@automattic/calypso-router'; -import { useTranslate } from 'i18n-calypso'; -import { useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { recordGoogleEvent } from 'calypso/state/analytics/actions'; -import { sendBillingReceiptEmail } from 'calypso/state/billing-transactions/actions'; -import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; -import type { IAppState } from 'calypso/state/types'; -import type { Action } from 'redux'; -import type { ThunkDispatch } from 'redux-thunk'; - -const recordClickEvent = ( eventAction: string ) => { - recordGoogleEvent( 'Me', eventAction ); -}; - -export type ReceiptAction = { - id: 'view-receipt' | 'email-receipt'; - label: string; - isPrimary: boolean; - iconName: string; - callback: ( items: BillingTransaction[] ) => void; -}; - -export function useReceiptActions( - getReceiptUrlFor: ( receiptId: string ) => string -): ReceiptAction[] { - const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); - const translate = useTranslate(); - - return useMemo( - () => [ - { - id: 'view-receipt', - label: translate( 'View receipt' ), - isPrimary: true, - iconName: 'pages', - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - pageRedirect.redirect( getReceiptUrlFor( item.id ) ); - }, - }, - { - id: 'email-receipt', - label: translate( 'Email receipt' ), - isPrimary: true, - iconName: 'mail', - callback: ( items: BillingTransaction[] ) => { - const item = items[ 0 ]; - recordClickEvent( 'Email Receipt in Billing History' ); - dispatch( sendBillingReceiptEmail( item.id ) ); - }, - }, - ], - [ dispatch, getReceiptUrlFor, translate ] - ); -} From d0c13b40c5e1aaf781d3d0310eef295e117afe84 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 6 Jan 2025 10:35:38 -0600 Subject: [PATCH 22/45] Renames file and fixes linting --- .../hooks/use-receipt-actions.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 client/me/purchases/billing-history/hooks/use-receipt-actions.ts diff --git a/client/me/purchases/billing-history/hooks/use-receipt-actions.ts b/client/me/purchases/billing-history/hooks/use-receipt-actions.ts new file mode 100644 index 0000000000000..97c5bb26d52f4 --- /dev/null +++ b/client/me/purchases/billing-history/hooks/use-receipt-actions.ts @@ -0,0 +1,56 @@ +import pageRedirect from '@automattic/calypso-router'; +import { useTranslate } from 'i18n-calypso'; +import { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { recordGoogleEvent } from 'calypso/state/analytics/actions'; +import { sendBillingReceiptEmail } from 'calypso/state/billing-transactions/actions'; +import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; +import type { IAppState } from 'calypso/state/types'; +import type { Action } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; + +const recordClickEvent = ( eventAction: string ) => { + recordGoogleEvent( 'Me', eventAction ); +}; + +export type ReceiptAction = { + id: 'view-receipt' | 'email-receipt'; + label: string; + isPrimary: boolean; + iconName: string; + callback: ( items: BillingTransaction[] ) => void; +}; + +export function useReceiptActions( + getReceiptUrlFor: ( receiptId: string ) => string +): ReceiptAction[] { + const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); + const translate = useTranslate(); + + return useMemo( + () => [ + { + id: 'view-receipt', + label: translate( 'View receipt' ), + isPrimary: true, + iconName: 'pages', + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + pageRedirect.redirect( getReceiptUrlFor( item.id ) ); + }, + }, + { + id: 'email-receipt', + label: translate( 'Email receipt' ), + isPrimary: true, + iconName: 'mail', + callback: ( items: BillingTransaction[] ) => { + const item = items[ 0 ]; + recordClickEvent( 'Email Receipt in Billing History' ); + dispatch( sendBillingReceiptEmail( item.id ) ); + }, + }, + ], + [ dispatch, getReceiptUrlFor, translate ] + ); +} From 78acebf8a4f9fbf60d9a33a93a62a6e810c45f85 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 6 Jan 2025 14:12:23 -0600 Subject: [PATCH 23/45] Removes column hiding --- .../billing-history-list-data-view.tsx | 2 +- .../billing-history/field-definitions.tsx | 16 +++++++--------- .../hooks/use-field-definitions.ts | 16 +++++++--------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 695495517da5b..d9734930cc173 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -39,8 +39,8 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { view.page, view.perPage ); - const fields = useFieldDefinitions( transactions, view ); const translate = useTranslate(); + const fields = useFieldDefinitions( transactions, translate ); return (
    diff --git a/client/me/purchases/billing-history/field-definitions.tsx b/client/me/purchases/billing-history/field-definitions.tsx index 6c45eea04f722..fd6b7ba92a5ff 100644 --- a/client/me/purchases/billing-history/field-definitions.tsx +++ b/client/me/purchases/billing-history/field-definitions.tsx @@ -106,7 +106,6 @@ const getUniqueTransactionTypes = ( export const getFieldDefinitions = ( transactions: BillingTransaction[] | null, - hiddenFields: string[], translate: ReturnType< typeof useTranslate > ) => ( { date: { @@ -116,9 +115,8 @@ export const getFieldDefinitions = ( width: '15%', elements: getUniqueMonths( transactions ?? [] ), enableGlobalSearch: true, - enableHiding: true, enableSorting: true, - isHidden: hiddenFields.includes( 'date' ), + enableHiding: false, filterBy: { operators: [ 'is' as Operator ], }, @@ -136,9 +134,8 @@ export const getFieldDefinitions = ( width: '45%', elements: getUniqueServices( transactions ?? [] ), enableGlobalSearch: true, - enableHiding: true, enableSorting: true, - isHidden: hiddenFields.includes( 'service' ), + enableHiding: false, filterBy: { operators: [ 'is' as Operator ], }, @@ -160,9 +157,8 @@ export const getFieldDefinitions = ( width: '20%', elements: getUniqueTransactionTypes( transactions ?? [] ), enableGlobalSearch: true, - enableHiding: true, enableSorting: true, - isHidden: hiddenFields.includes( 'type' ), + enableHiding: false, filterBy: { operators: [ 'is' as Operator ], }, @@ -181,9 +177,11 @@ export const getFieldDefinitions = ( type: 'text' as const, width: '20%', enableGlobalSearch: true, - enableHiding: true, enableSorting: true, - isHidden: hiddenFields.includes( 'amount' ), + enableHiding: false, + filterBy: { + operators: [ 'is' as Operator ], + }, getValue: ( { item }: { item: BillingTransaction } ) => { return item.amount_integer; }, diff --git a/client/me/purchases/billing-history/hooks/use-field-definitions.ts b/client/me/purchases/billing-history/hooks/use-field-definitions.ts index db6ebcf7724e5..8b1b9851adc60 100644 --- a/client/me/purchases/billing-history/hooks/use-field-definitions.ts +++ b/client/me/purchases/billing-history/hooks/use-field-definitions.ts @@ -1,16 +1,14 @@ import { useTranslate } from 'i18n-calypso'; import { useMemo } from 'react'; import { getFieldDefinitions } from '../field-definitions'; -import type { ViewState } from '../data-views-types'; import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; -export function useFieldDefinitions( transactions: BillingTransaction[] | null, view: ViewState ) { - const translate = useTranslate(); - +export function useFieldDefinitions( + transactions: BillingTransaction[] | null, + translate: ReturnType< typeof useTranslate > +) { return useMemo( () => { - const fieldDefinitions = getFieldDefinitions( transactions, view.hiddenFields, translate ); - return view.fields.map( - ( fieldId ) => fieldDefinitions[ fieldId as keyof typeof fieldDefinitions ] - ); - }, [ transactions, view.hiddenFields, view.fields, translate ] ); + const fieldDefinitions = getFieldDefinitions( transactions, translate ); + return Object.values( fieldDefinitions ); + }, [ transactions, translate ] ); } From 160b00f6172f9510036e58652e02b7007af5f86e Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 9 Jan 2025 13:08:40 -0600 Subject: [PATCH 24/45] Adds helper method for searching transaction amounts. --- .../hooks/use-transactions-filtering.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts index b95ef57bed0af..7c43676c30061 100644 --- a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts +++ b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts @@ -6,6 +6,16 @@ import { DATE_FORMATS } from '../constants'; import { groupDomainProducts } from '../utils'; import type { ViewState, Filter } from '../data-views-types'; +function currencyToInteger( value: string ): number | null { + const match = value.match( /\$?(\d+)\.?(\d{0,2})/ ); + if ( ! match ) { + return null; + } + const dollars = parseInt( match[ 1 ], 10 ); + const cents = match[ 2 ] ? parseInt( match[ 2 ].padEnd( 2, '0' ), 10 ) : 0; + return dollars * 100 + cents; +} + export function useTransactionsFiltering( transactions: BillingTransaction[] | null, view: ViewState @@ -17,9 +27,14 @@ export function useTransactionsFiltering( if ( view.search ) { const searchTerm = view.search.toLowerCase(); const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); + + const searchAmount = currencyToInteger( searchTerm ); + if ( searchAmount !== null && searchAmount === transaction.amount_integer ) { + return true; + } + const searchableFields = [ transaction.service, - transaction.amount_integer, transactionItem.product, transactionItem.variation, transactionItem.domain, From a83fbac380f9b853ae5a058c4b6141bbb765e0ec Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 9 Jan 2025 13:11:01 -0600 Subject: [PATCH 25/45] Adds testing for custom hooks --- .../test/paginate-transactions.ts | 287 ------------------ 1 file changed, 287 deletions(-) delete mode 100644 client/me/purchases/billing-history/test/paginate-transactions.ts diff --git a/client/me/purchases/billing-history/test/paginate-transactions.ts b/client/me/purchases/billing-history/test/paginate-transactions.ts deleted file mode 100644 index de5205c611666..0000000000000 --- a/client/me/purchases/billing-history/test/paginate-transactions.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { cloneDeep } from 'lodash'; -import { - BillingTransaction, - BillingTransactionItem, -} from 'calypso/state/billing-transactions/types'; -import { paginateTransactions } from '../filter-transactions'; - -const mockTransaction: BillingTransaction = { - currency: 'USD', - address: '', - amount: '', - amount_integer: 0, - tax_country_code: '', - cc_email: '', - cc_name: '', - cc_num: '', - cc_type: '', - credit: '', - date: '', - desc: '', - icon: '', - id: '', - items: [], - org: '', - pay_part: '', - pay_ref: '', - service: '', - subtotal: '', - subtotal_integer: 0, - support: '', - tax: '', - tax_integer: 0, - url: '', -}; - -const mockItem: BillingTransactionItem = { - id: '', - type: '', - type_localized: '', - domain: '', - site_id: '', - subtotal: '', - subtotal_integer: 0, - tax_integer: 0, - amount_integer: 0, - tax: '', - amount: '', - raw_subtotal: 0, - raw_tax: 0, - raw_amount: 0, - currency: '', - licensed_quantity: null, - new_quantity: null, - product: '', - product_slug: '', - variation: '', - variation_slug: '', - months_per_renewal_interval: 0, - wpcom_product_slug: '', -}; - -const past: BillingTransaction[] = [ - { - ...mockTransaction, - date: '2018-05-01T12:00:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-04-11T13:11:27', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$5.75', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$8.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-11T21:00:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$5.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-15T10:39:27', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.86', - type: 'new purchase', - variation: 'Variation1', - }, - { - ...mockItem, - amount: '$1.23', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-13T16:10:45', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-01-10T14:24:38', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.20', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-10T10:30:38', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.75', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-01T07:20:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$9.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-11-24T05:13:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$8.40', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-01-01T00:00:00', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$2.40', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, -]; - -const state = { - billingTransactions: { - items: { - past, - }, - }, -}; - -describe( 'paginateTransactions()', () => { - test( 'returns all transactions when there are fewer than the page limit', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length + 1; - const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns all transactions when there are the same number as the page limit', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length; - const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns a page from all transactions when there are more than the page limit', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length - 1; - const result = paginateTransactions( testState.billingTransactions.items.past, 1, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 0, pageSize ) ); - } ); - - test( 'returns the first page of transactions when no page is specified', () => { - const testState = cloneDeep( state ); - const pageSize = testState.billingTransactions.items.past.length - 1; - const result = paginateTransactions( testState.billingTransactions.items.past, null, pageSize ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 0, pageSize ) ); - } ); - - test( 'returns the second page of transactions when the second page is specified', () => { - const testState = cloneDeep( state ); - const result = paginateTransactions( testState.billingTransactions.items.past, 2, 3 ); - expect( result.length ).toEqual( 3 ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 3, 6 ) ); - expect( result[ 0 ].date ).toEqual( '2018-03-15T10:39:27' ); - expect( result[ 1 ].date ).toEqual( '2018-03-13T16:10:45' ); - expect( result[ 2 ].date ).toEqual( '2018-01-10T14:24:38' ); - } ); - - test( 'returns an abbreviated last page of transactions when the last page is specified', () => { - const testState = cloneDeep( state ); - const result = paginateTransactions( testState.billingTransactions.items.past, 4, 3 ); - expect( result.length ).toEqual( 1 ); - expect( result ).toEqual( state.billingTransactions.items.past.slice( 9, 10 ) ); - expect( result[ 0 ].date ).toEqual( '2017-01-01T00:00:00' ); - } ); -} ); From f9519dde023f9048eb03396ef421dbdff423e051 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 9 Jan 2025 14:47:45 -0600 Subject: [PATCH 26/45] Updates tests --- .../test-fixtures/billing-transactions.ts | 120 +++++++++++ .../test/use-pagination.test.ts | 118 +++++++++++ .../test/use-transactions-sorting.test.ts | 197 ++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 client/me/purchases/billing-history/test-fixtures/billing-transactions.ts create mode 100644 client/me/purchases/billing-history/test/use-pagination.test.ts create mode 100644 client/me/purchases/billing-history/test/use-transactions-sorting.test.ts diff --git a/client/me/purchases/billing-history/test-fixtures/billing-transactions.ts b/client/me/purchases/billing-history/test-fixtures/billing-transactions.ts new file mode 100644 index 0000000000000..78a665ff47a23 --- /dev/null +++ b/client/me/purchases/billing-history/test-fixtures/billing-transactions.ts @@ -0,0 +1,120 @@ +import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; + +export const createBaseTransaction = (): BillingTransaction => ( { + id: 'mock-transaction', + date: '2023-01-01', + service: 'WordPress.com', + amount: '$10.00', + amount_integer: 1000, + currency: 'USD', + items: [ + { + id: 'mock-item', + type: 'new purchase', + product: 'WordPress.com Plan', + domain: 'example.com', + variation: 'Standard', + type_localized: '', + site_id: '', + subtotal: '', + raw_subtotal: 0, + subtotal_integer: 0, + tax: '', + raw_tax: 0, + tax_integer: 0, + amount: '', + raw_amount: 0, + amount_integer: 0, + cost_overrides: [], + currency: '', + licensed_quantity: null, + new_quantity: null, + volume: null, + product_slug: '', + variation_slug: '', + months_per_renewal_interval: 0, + wpcom_product_slug: '', + }, + ], + address: '', + tax_country_code: '', + cc_email: '', + cc_name: '', + cc_num: '', + cc_type: '', + cc_display_brand: null, + credit: '', + desc: '', + icon: '', + org: '', + pay_part: '', + pay_ref: '', + subtotal: '', + subtotal_integer: 0, + support: '', + tax: '', + tax_integer: 0, + url: '', +} ); + +export const generateTransactions = ( count: number ): BillingTransaction[] => { + return Array.from( { length: count }, ( _, index ) => ( { + ...createBaseTransaction(), + id: `transaction-${ index + 1 }`, + } ) ); +}; + +// Sample transactions with different properties for filtering/sorting tests +export const mockTransactions: BillingTransaction[] = [ + { + ...createBaseTransaction(), + id: 'wp-premium', + service: 'WordPress.com', + amount: '$20.00', + amount_integer: 2000, + date: '2023-01-15', + items: [ + { + ...createBaseTransaction().items[ 0 ], + id: 'wp-premium-item', + type: 'new purchase', + product: 'WordPress.com Premium Plan', + variation: 'Premium', + }, + ], + }, + { + ...createBaseTransaction(), + id: 'jp-backup', + service: 'Jetpack', + amount: '$15.00', + amount_integer: 1500, + date: '2023-02-01', + items: [ + { + ...createBaseTransaction().items[ 0 ], + id: 'jp-backup-item', + type: 'renewal', + product: 'Jetpack Backup', + variation: 'Daily', + }, + ], + }, + { + ...createBaseTransaction(), + id: 'woo-basic', + service: 'Store Services', + amount: '$25.00', + amount_integer: 2500, + date: '2023-03-01', + items: [ + { + ...createBaseTransaction().items[ 0 ], + id: 'woo-basic-item', + type: 'cancellation', + product: 'WooCommerce Plan', + variation: 'Basic', + }, + ], + }, +]; diff --git a/client/me/purchases/billing-history/test/use-pagination.test.ts b/client/me/purchases/billing-history/test/use-pagination.test.ts new file mode 100644 index 0000000000000..cfec400c0c12b --- /dev/null +++ b/client/me/purchases/billing-history/test/use-pagination.test.ts @@ -0,0 +1,118 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook } from '@testing-library/react'; +import { usePagination } from '../hooks/use-pagination'; +import { generateTransactions } from '../test-fixtures/billing-transactions'; + +describe( 'usePagination', () => { + test( 'returns all transactions when there are fewer than perPage', () => { + const transactions = generateTransactions( 3 ); + const { result } = renderHook( () => usePagination( transactions, 1, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( transactions ); + expect( result.current.totalPages ).toBe( 1 ); + expect( result.current.hasNextPage ).toBe( false ); + expect( result.current.hasPreviousPage ).toBe( false ); + } ); + + test( 'returns first page correctly when there are multiple pages', () => { + const transactions = generateTransactions( 12 ); + const { result } = renderHook( () => usePagination( transactions, 1, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( transactions.slice( 0, 5 ) ); + expect( result.current.totalPages ).toBe( 3 ); + expect( result.current.hasNextPage ).toBe( true ); + expect( result.current.hasPreviousPage ).toBe( false ); + } ); + + test( 'returns correct page of transactions when there are more than perPage', () => { + const transactions = generateTransactions( 12 ); + const { result } = renderHook( () => usePagination( transactions, 2, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( transactions.slice( 5, 10 ) ); + expect( result.current.totalPages ).toBe( 3 ); + expect( result.current.hasNextPage ).toBe( true ); + expect( result.current.hasPreviousPage ).toBe( true ); + } ); + + test( 'returns last page correctly when not completely filled', () => { + const transactions = generateTransactions( 12 ); + const { result } = renderHook( () => usePagination( transactions, 3, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( transactions.slice( 10, 12 ) ); + expect( result.current.totalPages ).toBe( 3 ); + expect( result.current.hasNextPage ).toBe( false ); + expect( result.current.hasPreviousPage ).toBe( true ); + } ); + + test( 'handles exact page size correctly', () => { + const transactions = generateTransactions( 10 ); + const { result } = renderHook( () => usePagination( transactions, 2, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( transactions.slice( 5, 10 ) ); + expect( result.current.totalPages ).toBe( 2 ); + expect( result.current.hasNextPage ).toBe( false ); + expect( result.current.hasPreviousPage ).toBe( true ); + } ); + + test( 'handles empty transactions array', () => { + const { result } = renderHook( () => usePagination( [], 1, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( [] ); + expect( result.current.totalPages ).toBe( 0 ); + expect( result.current.hasNextPage ).toBe( false ); + expect( result.current.hasPreviousPage ).toBe( false ); + } ); + + test( 'handles invalid high page numbers gracefully', () => { + const transactions = generateTransactions( 10 ); + const { result } = renderHook( () => usePagination( transactions, 99, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( [] ); + expect( result.current.totalPages ).toBe( 2 ); + expect( result.current.hasNextPage ).toBe( false ); + expect( result.current.hasPreviousPage ).toBe( true ); + } ); + + test( 'handles zero page number by returning empty array', () => { + const transactions = generateTransactions( 10 ); + const { result } = renderHook( () => usePagination( transactions, 0, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( [] ); + expect( result.current.totalPages ).toBe( 2 ); + expect( result.current.hasNextPage ).toBe( true ); + expect( result.current.hasPreviousPage ).toBe( false ); + } ); + + test( 'handles negative page numbers by returning first page', () => { + const transactions = generateTransactions( 10 ); + const { result } = renderHook( () => usePagination( transactions, -1, 5 ) ); + + expect( result.current.paginatedItems ).toEqual( transactions.slice( 0, 5 ) ); + expect( result.current.totalPages ).toBe( 2 ); + expect( result.current.hasNextPage ).toBe( true ); + expect( result.current.hasPreviousPage ).toBe( false ); + } ); + + test( 'handles zero page size', () => { + const transactions = generateTransactions( 10 ); + const { result } = renderHook( () => usePagination( transactions, 1, 0 ) ); + + expect( result.current.paginatedItems ).toEqual( [] ); + expect( result.current.totalPages ).toBe( Infinity ); + expect( result.current.hasNextPage ).toBe( true ); + expect( result.current.hasPreviousPage ).toBe( false ); + } ); + + test( 'handles negative page size', () => { + const transactions = generateTransactions( 10 ); + const { result } = renderHook( () => usePagination( transactions, 1, -5 ) ); + + expect( result.current.paginatedItems ).toEqual( transactions.slice( 0, 5 ) ); + expect( result.current.totalPages ).toBe( -2 ); + expect( result.current.hasNextPage ).toBe( false ); + expect( result.current.hasPreviousPage ).toBe( false ); + } ); +} ); diff --git a/client/me/purchases/billing-history/test/use-transactions-sorting.test.ts b/client/me/purchases/billing-history/test/use-transactions-sorting.test.ts new file mode 100644 index 0000000000000..8818771f18d69 --- /dev/null +++ b/client/me/purchases/billing-history/test/use-transactions-sorting.test.ts @@ -0,0 +1,197 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook } from '@testing-library/react'; +import { useTransactionsSorting } from '../hooks/use-transactions-sorting'; +import { mockTransactions } from '../test-fixtures/billing-transactions'; + +describe( 'useTransactionsSorting', () => { + test( 'sorts transactions by date ascending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'date', direction: 'asc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current[ 0 ].date ).toBe( '2023-01-15' ); + expect( result.current[ 1 ].date ).toBe( '2023-02-01' ); + expect( result.current[ 2 ].date ).toBe( '2023-03-01' ); + } ); + + test( 'sorts transactions by date descending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'date', direction: 'desc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current[ 0 ].date ).toBe( '2023-03-01' ); + expect( result.current[ 1 ].date ).toBe( '2023-02-01' ); + expect( result.current[ 2 ].date ).toBe( '2023-01-15' ); + } ); + + test( 'sorts transactions by service ascending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'service', direction: 'asc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + // Should sort by variation first + expect( result.current[ 0 ].items[ 0 ].variation ).toBe( 'Basic' ); + expect( result.current[ 1 ].items[ 0 ].variation ).toBe( 'Daily' ); + expect( result.current[ 2 ].items[ 0 ].variation ).toBe( 'Premium' ); + } ); + + test( 'sorts transactions by service descending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'service', direction: 'desc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + // Should sort by variation first + expect( result.current[ 0 ].items[ 0 ].variation ).toBe( 'Premium' ); + expect( result.current[ 1 ].items[ 0 ].variation ).toBe( 'Daily' ); + expect( result.current[ 2 ].items[ 0 ].variation ).toBe( 'Basic' ); + } ); + + test( 'sorts transactions by type ascending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'type', direction: 'asc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current[ 0 ].items[ 0 ].type ).toBe( 'cancellation' ); + expect( result.current[ 1 ].items[ 0 ].type ).toBe( 'new purchase' ); + expect( result.current[ 2 ].items[ 0 ].type ).toBe( 'renewal' ); + } ); + + test( 'sorts transactions by type descending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'type', direction: 'desc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current[ 0 ].items[ 0 ].type ).toBe( 'renewal' ); + expect( result.current[ 1 ].items[ 0 ].type ).toBe( 'new purchase' ); + expect( result.current[ 2 ].items[ 0 ].type ).toBe( 'cancellation' ); + } ); + + test( 'sorts transactions by amount ascending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'amount', direction: 'asc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current[ 0 ].amount_integer ).toBe( 1500 ); // $15.00 + expect( result.current[ 1 ].amount_integer ).toBe( 2000 ); // $20.00 + expect( result.current[ 2 ].amount_integer ).toBe( 2500 ); // $25.00 + } ); + + test( 'sorts transactions by amount descending', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'amount', direction: 'desc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current[ 0 ].amount_integer ).toBe( 2500 ); // $25.00 + expect( result.current[ 1 ].amount_integer ).toBe( 2000 ); // $20.00 + expect( result.current[ 2 ].amount_integer ).toBe( 1500 ); // $15.00 + } ); + + test( 'handles unknown sort field by returning original order', () => { + const { result } = renderHook( () => + useTransactionsSorting( mockTransactions, { + sort: { field: 'unknown' as any, direction: 'asc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current ).toEqual( mockTransactions ); + } ); + + test( 'handles empty transactions array', () => { + const { result } = renderHook( () => + useTransactionsSorting( [], { + sort: { field: 'date', direction: 'asc' }, + type: 'table', + search: '', + filters: [], + page: 0, + perPage: 0, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current ).toEqual( [] ); + } ); +} ); From 8f692a8d211126b07503e9d31141d278e86b31b9 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 9 Jan 2025 16:13:26 -0600 Subject: [PATCH 27/45] Remove amount search --- .../hooks/use-transactions-filtering.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts index 7c43676c30061..2814168b81dfa 100644 --- a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts +++ b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts @@ -6,16 +6,6 @@ import { DATE_FORMATS } from '../constants'; import { groupDomainProducts } from '../utils'; import type { ViewState, Filter } from '../data-views-types'; -function currencyToInteger( value: string ): number | null { - const match = value.match( /\$?(\d+)\.?(\d{0,2})/ ); - if ( ! match ) { - return null; - } - const dollars = parseInt( match[ 1 ], 10 ); - const cents = match[ 2 ] ? parseInt( match[ 2 ].padEnd( 2, '0' ), 10 ) : 0; - return dollars * 100 + cents; -} - export function useTransactionsFiltering( transactions: BillingTransaction[] | null, view: ViewState @@ -28,11 +18,6 @@ export function useTransactionsFiltering( const searchTerm = view.search.toLowerCase(); const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); - const searchAmount = currencyToInteger( searchTerm ); - if ( searchAmount !== null && searchAmount === transaction.amount_integer ) { - return true; - } - const searchableFields = [ transaction.service, transactionItem.product, From 45f1984593e3a6785497567dcc1b3de21a58c9df Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Thu, 9 Jan 2025 16:35:26 -0600 Subject: [PATCH 28/45] Add filtering and view tests --- .../test/filter-transactions.ts | 403 ------------------ .../test/use-transactions-filtering.test.ts | 232 ++++++++++ .../test/use-view-state-update.test.ts | 111 +++++ 3 files changed, 343 insertions(+), 403 deletions(-) delete mode 100644 client/me/purchases/billing-history/test/filter-transactions.ts create mode 100644 client/me/purchases/billing-history/test/use-transactions-filtering.test.ts create mode 100644 client/me/purchases/billing-history/test/use-view-state-update.test.ts diff --git a/client/me/purchases/billing-history/test/filter-transactions.ts b/client/me/purchases/billing-history/test/filter-transactions.ts deleted file mode 100644 index f2911858acdd4..0000000000000 --- a/client/me/purchases/billing-history/test/filter-transactions.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { cloneDeep } from 'lodash'; -import { - BillingTransaction, - BillingTransactionItem, -} from 'calypso/state/billing-transactions/types'; -import getBillingTransactionFilters from 'calypso/state/selectors/get-billing-transaction-filters'; -import { filterTransactions } from '../filter-transactions'; - -const mockTransaction: BillingTransaction = { - currency: 'USD', - address: '', - amount: '', - amount_integer: 0, - tax_country_code: '', - cc_email: '', - cc_name: '', - cc_num: '', - cc_type: '', - credit: '', - date: '', - desc: '', - icon: '', - id: '', - items: [], - org: '', - pay_part: '', - pay_ref: '', - service: '', - subtotal: '', - subtotal_integer: 0, - support: '', - tax: '', - tax_integer: 0, - url: '', -}; - -const mockItem: BillingTransactionItem = { - id: '', - type: '', - type_localized: '', - domain: '', - site_id: '', - subtotal: '', - subtotal_integer: 0, - tax_integer: 0, - amount_integer: 0, - tax: '', - amount: '', - raw_subtotal: 0, - raw_tax: 0, - raw_amount: 0, - currency: '', - licensed_quantity: null, - new_quantity: null, - product: '', - product_slug: '', - variation: '', - variation_slug: '', - months_per_renewal_interval: 0, - wpcom_product_slug: '', -}; - -const past: BillingTransaction[] = [ - { - ...mockTransaction, - date: '2018-05-01T12:00:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-04-11T13:11:27', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$5.75', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$8.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-11T21:00:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation2', - }, - { - ...mockItem, - amount: '$5.00', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-15T10:39:27', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.86', - type: 'new purchase', - variation: 'Variation1', - }, - { - ...mockItem, - amount: '$1.23', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-03-13T16:10:45', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2018-01-10T14:24:38', - service: 'WordPress.com', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$4.20', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-10T10:30:38', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$3.75', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-12-01T07:20:00', - service: 'Store Services', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$9.50', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, - { - ...mockTransaction, - date: '2017-11-24T05:13:00', - service: 'WordPress.com', - cc_name: 'name1 surname1', - cc_type: 'visa', - items: [ - { - ...mockItem, - amount: '$8.40', - type: 'new purchase', - variation: 'Variation2', - }, - ], - }, - { - ...mockTransaction, - date: '2017-01-01T00:00:00', - service: 'Store Services', - cc_name: 'name2', - cc_type: 'mastercard', - items: [ - { - ...mockItem, - amount: '$2.40', - type: 'new purchase', - variation: 'Variation1', - }, - ], - }, -]; - -const state = { - billingTransactions: { - items: { - past, - }, - ui: { past: undefined, upcoming: undefined }, - }, -}; - -describe( 'filterTransactions()', () => { - describe( 'date filter', () => { - test( 'returns all transactions when filtering by newest', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: null, operator: null }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns transactions filtered by month', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-03', operator: 'equal' }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result ).toHaveLength( 3 ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); - expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 2 ); - expect( new Date( result[ 2 ].date ).getMonth() ).toBe( 2 ); - } ); - - test( 'returns transactions before the month set in the filter', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2017-12', operator: 'before' }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 10 ); - expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 0 ); - } ); - } ); - - describe( 'app filter', () => { - test( 'returns all transactions when the filter is empty', () => { - const filter = getBillingTransactionFilters( state as any, 'past' ); - const result = filterTransactions( state.billingTransactions.items.past, filter, null ); - expect( result ).toEqual( state.billingTransactions.items.past ); - } ); - - test( 'returns transactions filtered by app name', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - app: 'Store Services', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - result.forEach( ( transaction ) => { - expect( transaction.service ).toEqual( 'Store Services' ); - } ); - } ); - } ); - - describe( 'search query', () => { - test( 'query matches a field in the root transaction object', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - query: 'mastercard', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( state.billingTransactions.items.past, filter, null ); - result.forEach( ( transaction ) => { - expect( transaction.cc_type ).toEqual( 'mastercard' ); - } ); - } ); - - test( 'query matches date of a transaction', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - query: 'may 1', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - result.forEach( ( transaction ) => { - expect( transaction.date ).toBe( '2018-05-01T12:00:00' ); - } ); - } ); - - test( 'query matches a field in the transaction items array', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - query: '$3.50', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); - expect( result[ 1 ].items ).toMatchObject( [ { amount: '$3.50' }, { amount: '$5.00' } ] ); - expect( result[ 2 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); - } ); - } ); - - describe( 'filter combinations', () => { - test( 'date and app filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-03', operator: 'equal' }, - app: 'Store Services', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); - expect( result[ 0 ].service ).toEqual( 'Store Services' ); - expect( new Date( result[ 1 ].date ).getMonth() ).toBe( 2 ); - expect( result[ 1 ].service ).toEqual( 'Store Services' ); - } ); - - test( 'app and query filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - app: 'Store Services', - query: '$3.50', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' }, { amount: '$5.00' } ] ); - expect( result[ 0 ].service ).toEqual( 'Store Services' ); - } ); - - test( 'date and query filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-05', operator: 'equal' }, - query: '$3.50', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].items ).toMatchObject( [ { amount: '$3.50' } ] ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 4 ); - } ); - - test( 'app, date and query filters', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2018-03', operator: 'equal' }, - query: 'visa', - app: 'WordPress.com', - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result[ 0 ].cc_type ).toEqual( 'visa' ); - expect( new Date( result[ 0 ].date ).getMonth() ).toBe( 2 ); - expect( result[ 0 ].service ).toEqual( 'WordPress.com' ); - } ); - } ); - - describe( 'no results', () => { - test( 'should return all expected meta fields including an empty transactions array', () => { - const testState = cloneDeep( state ); - testState.billingTransactions.ui.past = { - date: { month: '2019-01', operator: 'equal' }, - }; - const filter = getBillingTransactionFilters( testState as any, 'past' ); - const result = filterTransactions( testState.billingTransactions.items.past, filter, null ); - expect( result ).toEqual( [] ); - } ); - } ); -} ); diff --git a/client/me/purchases/billing-history/test/use-transactions-filtering.test.ts b/client/me/purchases/billing-history/test/use-transactions-filtering.test.ts new file mode 100644 index 0000000000000..de4f7290c4a64 --- /dev/null +++ b/client/me/purchases/billing-history/test/use-transactions-filtering.test.ts @@ -0,0 +1,232 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook } from '@testing-library/react'; +import { useTransactionsFiltering } from '../hooks/use-transactions-filtering'; +import { mockTransactions } from '../test-fixtures/billing-transactions'; + +describe( 'useTransactionsFiltering', () => { + test( 'returns all transactions when no filters are applied', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: '', + filters: [], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current ).toEqual( mockTransactions ); + } ); + + test( 'filters transactions by search term matching service', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: 'Jetpack', + filters: [], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current.length ).toBe( 1 ); + expect( result.current[ 0 ].service ).toBe( 'Jetpack' ); + } ); + + test( 'filters transactions by service type', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: '', + filters: [ + { + field: 'service', + value: 'Store Services', + operator: 'is', + }, + ], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current.length ).toBe( 1 ); + expect( result.current[ 0 ].service ).toBe( 'Store Services' ); + } ); + + test( 'filters transactions by purchase type', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: '', + filters: [ + { + field: 'type', + value: 'renewal', + operator: 'is', + }, + ], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current.length ).toBe( 1 ); + expect( result.current[ 0 ].items[ 0 ].type ).toBe( 'renewal' ); + } ); + + test( 'filters transactions by date', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: '', + filters: [ + { + field: 'date', + value: '2023-02', + operator: 'is', + }, + ], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current.length ).toBe( 1 ); + expect( result.current[ 0 ].date ).toBe( '2023-02-01' ); + } ); + + test( 'combines multiple filters', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: '', + filters: [ + { + field: 'service', + value: 'WordPress.com', + operator: 'is', + }, + { + field: 'type', + value: 'new purchase', + operator: 'is', + }, + { + field: 'date', + value: '2023-01', + operator: 'is', + }, + ], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current.length ).toBe( 1 ); + expect( result.current[ 0 ].service ).toBe( 'WordPress.com' ); + expect( result.current[ 0 ].items[ 0 ].type ).toBe( 'new purchase' ); + expect( result.current[ 0 ].date ).toBe( '2023-01-15' ); + } ); + + test( 'handles null transactions array', () => { + const { result } = renderHook( () => + useTransactionsFiltering( null, { + search: 'test', + filters: [], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current ).toEqual( [] ); + } ); + + test( 'search is case insensitive', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: 'jetpack', + filters: [], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current.length ).toBe( 1 ); + expect( result.current[ 0 ].service ).toBe( 'Jetpack' ); + } ); + + test( 'search matches partial strings', () => { + const { result } = renderHook( () => + useTransactionsFiltering( mockTransactions, { + search: 'jet', + filters: [], + type: 'table', + page: 0, + perPage: 0, + sort: { + field: 'service', + direction: 'asc', + }, + fields: [], + hiddenFields: [], + } ) + ); + + expect( result.current.length ).toBe( 1 ); + expect( result.current[ 0 ].service ).toBe( 'Jetpack' ); + } ); +} ); diff --git a/client/me/purchases/billing-history/test/use-view-state-update.test.ts b/client/me/purchases/billing-history/test/use-view-state-update.test.ts new file mode 100644 index 0000000000000..97c691ee09d5c --- /dev/null +++ b/client/me/purchases/billing-history/test/use-view-state-update.test.ts @@ -0,0 +1,111 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook, act } from '@testing-library/react'; +import { type Operator } from '@wordpress/dataviews'; +import { defaultDataViewsState } from '../constants'; +import { useViewStateUpdate } from '../hooks/use-view-state-update'; + +describe( 'useViewStateUpdate', () => { + test( 'initializes with default state', () => { + const { result } = renderHook( () => useViewStateUpdate() ); + expect( result.current.view ).toEqual( defaultDataViewsState ); + } ); + + test( 'updates page number', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + + await act( async () => { + result.current.updateView( { page: 2 } ); + } ); + + expect( result.current.view.page ).toBe( 2 ); + } ); + + test( 'updates perPage and resets to page 1', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + + await act( async () => { + result.current.updateView( { perPage: 50 } ); + } ); + + expect( result.current.view.perPage ).toBe( 50 ); + expect( result.current.view.page ).toBe( 1 ); + } ); + + test( 'updates sort and resets to page 1', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + + await act( async () => { + result.current.updateView( { + sort: { field: 'date', direction: 'desc' }, + } ); + } ); + + expect( result.current.view.sort ).toEqual( { field: 'date', direction: 'desc' } ); + expect( result.current.view.page ).toBe( 1 ); + } ); + + test( 'updates filters and resets to page 1', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + const newFilters = [ { field: 'service', value: 'Jetpack', operator: 'is' as Operator } ]; + + await act( async () => { + result.current.updateView( { filters: newFilters } ); + } ); + + expect( result.current.view.filters ).toEqual( newFilters ); + expect( result.current.view.page ).toBe( 1 ); + } ); + + test( 'updates search and resets to page 1', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + + await act( async () => { + result.current.updateView( { search: 'test' } ); + } ); + + expect( result.current.view.search ).toBe( 'test' ); + expect( result.current.view.page ).toBe( 1 ); + } ); + + test( 'updates fields without resetting page', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + const newFields = [ 'date', 'amount' ]; + + await act( async () => { + result.current.updateView( { fields: newFields } ); + } ); + + expect( result.current.view.fields ).toEqual( newFields ); + expect( result.current.view.page ).toBe( defaultDataViewsState.page ); + } ); + + test( 'does not reset page when explicitly setting page with other updates', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + + await act( async () => { + result.current.updateView( { + page: 3, + sort: { field: 'date', direction: 'desc' }, + } ); + } ); + + expect( result.current.view.sort ).toEqual( { field: 'date', direction: 'desc' } ); + expect( result.current.view.page ).toBe( 3 ); + } ); + + test( 'does not update state when new values are the same as current', async () => { + const { result } = renderHook( () => useViewStateUpdate() ); + + await act( async () => { + result.current.updateView( { + perPage: defaultDataViewsState.perPage, + search: defaultDataViewsState.search, + } ); + } ); + + expect( result.current.view ).toEqual( defaultDataViewsState ); + } ); +} ); From ee8264083aa7e683747f8544d02c24d9f9c3db2f Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 12:06:28 -0600 Subject: [PATCH 29/45] Remove use of isEqual from Lodash --- .../hooks/use-view-state-update.ts | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-view-state-update.ts b/client/me/purchases/billing-history/hooks/use-view-state-update.ts index f3fd9da2280e2..54b77b6689de1 100644 --- a/client/me/purchases/billing-history/hooks/use-view-state-update.ts +++ b/client/me/purchases/billing-history/hooks/use-view-state-update.ts @@ -1,8 +1,41 @@ -import { isEqual } from 'lodash'; import { useState, useCallback } from 'react'; import { defaultDataViewsState } from '../constants'; import type { ViewState, ViewStateUpdate, SortableField } from '../data-views-types'; +type Sort = + | undefined + | { + field: string; + direction: 'asc' | 'desc'; + }; + +type Filters = undefined | Record< string, unknown >; + +function areSortsEqual( a: Sort, b: Sort ): boolean { + if ( a?.field !== b?.field ) { + return false; + } + if ( a?.direction !== b?.direction ) { + return false; + } + return true; +} + +function areFiltersEqual( a: Filters, b: Filters ): boolean { + if ( a === b ) { + return true; + } + if ( ! a || ! b ) { + return false; + } + const aKeys = Object.keys( a ); + const bKeys = Object.keys( b ); + if ( aKeys.length !== bKeys.length ) { + return false; + } + return aKeys.every( ( key ) => a[ key ] === b[ key ] ); +} + export function useViewStateUpdate() { const [ view, setView ] = useState< ViewState >( defaultDataViewsState ); @@ -25,7 +58,7 @@ export function useViewStateUpdate() { scrollToTop(); } - if ( newView.sort && ! isEqual( newView.sort, currentView.sort ) ) { + if ( newView.sort && ! areSortsEqual( newView.sort, currentView.sort ) ) { updatedView.sort = { field: newView.sort.field as SortableField, direction: newView.sort.direction, @@ -36,7 +69,7 @@ export function useViewStateUpdate() { } } - if ( newView.filters && ! isEqual( newView.filters, currentView.filters ) ) { + if ( newView.filters && ! areFiltersEqual( newView.filters, currentView.filters ) ) { updatedView.filters = newView.filters; if ( newView.page === undefined ) { updatedView.page = 1; From 244b480c4d577911f57ff8f08704848271f2d7f0 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 12:33:32 -0600 Subject: [PATCH 30/45] Improve type usage --- .../hooks/use-view-state-update.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-view-state-update.ts b/client/me/purchases/billing-history/hooks/use-view-state-update.ts index 54b77b6689de1..f45b0d2c44cbb 100644 --- a/client/me/purchases/billing-history/hooks/use-view-state-update.ts +++ b/client/me/purchases/billing-history/hooks/use-view-state-update.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { defaultDataViewsState } from '../constants'; -import type { ViewState, ViewStateUpdate, SortableField } from '../data-views-types'; +import type { ViewState, ViewStateUpdate, SortableField, Filter } from '../data-views-types'; type Sort = | undefined @@ -9,7 +9,14 @@ type Sort = direction: 'asc' | 'desc'; }; -type Filters = undefined | Record< string, unknown >; +type Filters = undefined | Filter[]; + +function verifySortField( field: string ): field is SortableField { + if ( ! [ 'date', 'service', 'type', 'amount' ].includes( field ) ) { + return false; + } + return true; +} function areSortsEqual( a: Sort, b: Sort ): boolean { if ( a?.field !== b?.field ) { @@ -28,12 +35,15 @@ function areFiltersEqual( a: Filters, b: Filters ): boolean { if ( ! a || ! b ) { return false; } - const aKeys = Object.keys( a ); - const bKeys = Object.keys( b ); - if ( aKeys.length !== bKeys.length ) { + if ( a.length !== b.length ) { return false; } - return aKeys.every( ( key ) => a[ key ] === b[ key ] ); + return a.every( + ( filter, index ) => + filter.field === b[ index ].field && + filter.operator === b[ index ].operator && + filter.value === b[ index ].value + ); } export function useViewStateUpdate() { @@ -45,7 +55,7 @@ export function useViewStateUpdate() { const updateView = ( newView: ViewStateUpdate ) => { setView( ( currentView ) => { - const updatedView = { ...currentView } as ViewState; + const updatedView = { ...currentView }; if ( newView.page !== undefined ) { updatedView.page = newView.page; @@ -59,13 +69,16 @@ export function useViewStateUpdate() { } if ( newView.sort && ! areSortsEqual( newView.sort, currentView.sort ) ) { - updatedView.sort = { - field: newView.sort.field as SortableField, - direction: newView.sort.direction, - }; - if ( newView.page === undefined ) { - updatedView.page = 1; - scrollToTop(); + // Skip invalid sort fields, keeping the current sort settings + if ( verifySortField( newView.sort.field ) ) { + updatedView.sort = { + field: newView.sort.field, + direction: newView.sort.direction, + }; + if ( newView.page === undefined ) { + updatedView.page = 1; + scrollToTop(); + } } } From 3185ef955b8f4246f18255a62379503354770ba3 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 12:47:57 -0600 Subject: [PATCH 31/45] Incorporate useCallback() in useViewStateUpdate() --- .../hooks/use-view-state-update.ts | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-view-state-update.ts b/client/me/purchases/billing-history/hooks/use-view-state-update.ts index f45b0d2c44cbb..5dba349e65c07 100644 --- a/client/me/purchases/billing-history/hooks/use-view-state-update.ts +++ b/client/me/purchases/billing-history/hooks/use-view-state-update.ts @@ -53,58 +53,60 @@ export function useViewStateUpdate() { window.scrollTo( { top: 0, behavior: 'smooth' } ); }, [] ); - const updateView = ( newView: ViewStateUpdate ) => { - setView( ( currentView ) => { - const updatedView = { ...currentView }; - - if ( newView.page !== undefined ) { - updatedView.page = newView.page; - scrollToTop(); - } - - if ( newView.perPage !== undefined && newView.perPage !== currentView.perPage ) { - updatedView.perPage = newView.perPage; - updatedView.page = 1; - scrollToTop(); - } - - if ( newView.sort && ! areSortsEqual( newView.sort, currentView.sort ) ) { - // Skip invalid sort fields, keeping the current sort settings - if ( verifySortField( newView.sort.field ) ) { - updatedView.sort = { - field: newView.sort.field, - direction: newView.sort.direction, - }; + const updateView = useCallback( + ( newView: ViewStateUpdate ) => { + setView( ( currentView ) => { + const updatedView = { ...currentView }; + + if ( newView.page !== undefined ) { + updatedView.page = newView.page; + scrollToTop(); + } + + if ( newView.perPage !== undefined && newView.perPage !== currentView.perPage ) { + updatedView.perPage = newView.perPage; + updatedView.page = 1; + scrollToTop(); + } + + if ( newView.sort && ! areSortsEqual( newView.sort, currentView.sort ) ) { + if ( verifySortField( newView.sort.field ) ) { + updatedView.sort = { + field: newView.sort.field, + direction: newView.sort.direction, + }; + if ( newView.page === undefined ) { + updatedView.page = 1; + scrollToTop(); + } + } + } + + if ( newView.filters && ! areFiltersEqual( newView.filters, currentView.filters ) ) { + updatedView.filters = newView.filters; if ( newView.page === undefined ) { updatedView.page = 1; scrollToTop(); } } - } - if ( newView.filters && ! areFiltersEqual( newView.filters, currentView.filters ) ) { - updatedView.filters = newView.filters; - if ( newView.page === undefined ) { - updatedView.page = 1; - scrollToTop(); + if ( newView.search !== undefined && newView.search !== currentView.search ) { + updatedView.search = newView.search; + if ( newView.page === undefined ) { + updatedView.page = 1; + scrollToTop(); + } } - } - if ( newView.search !== undefined && newView.search !== currentView.search ) { - updatedView.search = newView.search; - if ( newView.page === undefined ) { - updatedView.page = 1; - scrollToTop(); + if ( newView.fields !== undefined ) { + updatedView.fields = newView.fields; } - } - - if ( newView.fields !== undefined ) { - updatedView.fields = newView.fields; - } - return updatedView; - } ); - }; + return updatedView; + } ); + }, + [ scrollToTop ] + ); return { view, From 506d5e019a38505a7dac62d4e52d5456c3d0a91d Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 12:58:22 -0600 Subject: [PATCH 32/45] Assign viewState directly to useViewStateUpdate() --- .../billing-history-list-data-view.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index d9734930cc173..197ef82158025 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -24,7 +24,7 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { } ) => { const transactions = useSelector( getPastBillingTransactions ); const isLoading = useSelector( isRequestingBillingTransactions ); - const { view, updateView } = useViewStateUpdate(); + const viewState = useViewStateUpdate(); const receiptActions = useReceiptActions( getReceiptUrlFor ); const actions = receiptActions.map( ( action ) => ( { @@ -32,12 +32,12 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { icon: , } ) ); - const filteredTransactions = useTransactionsFiltering( transactions, view ); - const sortedTransactions = useTransactionsSorting( filteredTransactions, view ); + const filteredTransactions = useTransactionsFiltering( transactions, viewState.view ); + const sortedTransactions = useTransactionsSorting( filteredTransactions, viewState.view ); const { paginatedItems, totalPages, totalItems } = usePagination( sortedTransactions, - view.page, - view.perPage + viewState.view.page, + viewState.view.perPage ); const translate = useTranslate(); const fields = useFieldDefinitions( transactions, translate ); @@ -52,10 +52,10 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { totalPages, } } fields={ fields } - view={ view } + view={ viewState.view } search searchLabel={ translate( 'Search receipts' ) } - onChangeView={ updateView } + onChangeView={ viewState.updateView } defaultLayouts={ { table: {} } } actions={ actions } isLoading={ isLoading } From 03c38f038da48ac5b716f349e0ca8ca957e0b92c Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 13:03:00 -0600 Subject: [PATCH 33/45] Call useTranslate() directly in useFieldDefinitions() --- .../billing-history/billing-history-list-data-view.tsx | 2 +- .../billing-history/hooks/use-field-definitions.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 197ef82158025..3e8e38da6da17 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -40,7 +40,7 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { viewState.view.perPage ); const translate = useTranslate(); - const fields = useFieldDefinitions( transactions, translate ); + const fields = useFieldDefinitions( transactions ); return (
    diff --git a/client/me/purchases/billing-history/hooks/use-field-definitions.ts b/client/me/purchases/billing-history/hooks/use-field-definitions.ts index 8b1b9851adc60..a89362397e822 100644 --- a/client/me/purchases/billing-history/hooks/use-field-definitions.ts +++ b/client/me/purchases/billing-history/hooks/use-field-definitions.ts @@ -3,10 +3,9 @@ import { useMemo } from 'react'; import { getFieldDefinitions } from '../field-definitions'; import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; -export function useFieldDefinitions( - transactions: BillingTransaction[] | null, - translate: ReturnType< typeof useTranslate > -) { +export function useFieldDefinitions( transactions: BillingTransaction[] | null ) { + const translate = useTranslate(); + return useMemo( () => { const fieldDefinitions = getFieldDefinitions( transactions, translate ); return Object.values( fieldDefinitions ); From 5b2db555806edf3c1bcf55e3cd5ddf1ac90d1d90 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 13:48:13 -0600 Subject: [PATCH 34/45] Account for unexpected item values --- .../billing-history/hooks/use-receipt-actions.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/me/purchases/billing-history/hooks/use-receipt-actions.ts b/client/me/purchases/billing-history/hooks/use-receipt-actions.ts index 97c5bb26d52f4..680caa3ad3850 100644 --- a/client/me/purchases/billing-history/hooks/use-receipt-actions.ts +++ b/client/me/purchases/billing-history/hooks/use-receipt-actions.ts @@ -35,7 +35,13 @@ export function useReceiptActions( isPrimary: true, iconName: 'pages', callback: ( items: BillingTransaction[] ) => { + if ( ! items || ! Array.isArray( items ) ) { + return; + } const item = items[ 0 ]; + if ( ! item?.id ) { + return; + } pageRedirect.redirect( getReceiptUrlFor( item.id ) ); }, }, @@ -45,7 +51,13 @@ export function useReceiptActions( isPrimary: true, iconName: 'mail', callback: ( items: BillingTransaction[] ) => { + if ( ! items || ! Array.isArray( items ) ) { + return; + } const item = items[ 0 ]; + if ( ! item?.id ) { + return; + } recordClickEvent( 'Email Receipt in Billing History' ); dispatch( sendBillingReceiptEmail( item.id ) ); }, From c2591d18b3df70718a632f3baaa2c988d096de67 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 13:55:15 -0600 Subject: [PATCH 35/45] Remove Moment reference from BillingHistoryListDataView --- .../billing-history/billing-history-list-data-view.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 3e8e38da6da17..0a8700719868d 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -2,7 +2,6 @@ import { Gridicon } from '@automattic/components'; import { DataViews } from '@wordpress/dataviews'; import { useTranslate } from 'i18n-calypso'; import { useSelector } from 'react-redux'; -import { withLocalizedMoment } from 'calypso/components/localized-moment'; import getPastBillingTransactions from 'calypso/state/selectors/get-past-billing-transactions'; import isRequestingBillingTransactions from 'calypso/state/selectors/is-requesting-billing-transactions'; import { useFieldDefinitions } from './hooks/use-field-definitions'; @@ -19,9 +18,9 @@ export interface BillingHistoryListProps { getReceiptUrlFor: ( receiptId: string ) => string; } -const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( { +export default function BillingHistoryListDataView( { getReceiptUrlFor, -} ) => { +}: BillingHistoryListProps ) { const transactions = useSelector( getPastBillingTransactions ); const isLoading = useSelector( isRequestingBillingTransactions ); const viewState = useViewStateUpdate(); @@ -63,6 +62,4 @@ const BillingHistoryListDataView: React.FC< BillingHistoryListProps > = ( {
    ); -}; - -export default withLocalizedMoment( BillingHistoryListDataView ); +} From a5fb805a50129a5ff3ea006b5f4e3b4ffba62301 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 14:51:23 -0600 Subject: [PATCH 36/45] Remove use of moment library --- .../billing-history-list-data-view.tsx | 3 +- .../me/purchases/billing-history/constants.ts | 6 ---- .../billing-history/field-definitions.tsx | 32 ++++++++++--------- .../hooks/use-transactions-filtering.ts | 13 ++++---- client/me/purchases/billing-history/utils.tsx | 21 ++++++++++++ 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 0a8700719868d..cdc2727e424f2 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -40,6 +40,7 @@ export default function BillingHistoryListDataView( { ); const translate = useTranslate(); const fields = useFieldDefinitions( transactions ); + const defaultLayout = { table: {} }; return (
    @@ -55,7 +56,7 @@ export default function BillingHistoryListDataView( { search searchLabel={ translate( 'Search receipts' ) } onChangeView={ viewState.updateView } - defaultLayouts={ { table: {} } } + defaultLayouts={ defaultLayout } actions={ actions } isLoading={ isLoading } /> diff --git a/client/me/purchases/billing-history/constants.ts b/client/me/purchases/billing-history/constants.ts index b75f25f488b85..5c6fddec71f6a 100644 --- a/client/me/purchases/billing-history/constants.ts +++ b/client/me/purchases/billing-history/constants.ts @@ -3,12 +3,6 @@ import type { ViewState } from './data-views-types'; export const DEFAULT_PAGE = 1; export const DEFAULT_PER_PAGE = 10; -export const DATE_FORMATS = { - MONTH_YEAR: 'YYYY-MM', - MONTH_YEAR_LABEL: 'MMMM YYYY', - DISPLAY: 'll', -} as const; - export const defaultDataViewsState: ViewState = { type: 'table', search: '', diff --git a/client/me/purchases/billing-history/field-definitions.tsx b/client/me/purchases/billing-history/field-definitions.tsx index fd6b7ba92a5ff..5c3fa406c7625 100644 --- a/client/me/purchases/billing-history/field-definitions.tsx +++ b/client/me/purchases/billing-history/field-definitions.tsx @@ -1,13 +1,14 @@ import { type Operator } from '@wordpress/dataviews'; import { useTranslate } from 'i18n-calypso'; -import moment from 'moment'; import { capitalPDangit } from 'calypso/lib/formatting'; -import { DATE_FORMATS } from './constants'; import { getTransactionTermLabel, groupDomainProducts, TransactionAmount, renderTransactionQuantitySummary, + formatDisplayDate, + formatMonthYear, + formatMonthYearLabel, } from './utils'; import type { BillingTransaction, @@ -55,18 +56,19 @@ const serviceName = ( const getUniqueMonths = ( transactions: BillingTransaction[] ): Array< { value: string; label: string } > => { - const uniqueMonths = new Set( - transactions.map( ( transaction ) => - moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) - ) - ); + const monthsMap = new Map< string, Date >(); - return Array.from( uniqueMonths ) - .sort() - .reverse() - .map( ( monthStr ) => ( { - value: monthStr, - label: moment( monthStr ).format( DATE_FORMATS.MONTH_YEAR_LABEL ), + transactions.forEach( ( transaction ) => { + const date = new Date( transaction.date ); + const formatted = formatMonthYear( date ); + monthsMap.set( formatted, date ); + } ); + + return Array.from( monthsMap.entries() ) + .sort( ( [ , dateA ], [ , dateB ] ) => dateB.getTime() - dateA.getTime() ) + .map( ( [ value, date ] ) => ( { + value, + label: formatMonthYearLabel( date ), } ) ); }; @@ -121,10 +123,10 @@ export const getFieldDefinitions = ( operators: [ 'is' as Operator ], }, getValue: ( { item }: { item: BillingTransaction } ) => { - return moment( item.date ).format( DATE_FORMATS.MONTH_YEAR ); + return formatMonthYear( new Date( item.date ) ); }, render: ( { item }: { item: BillingTransaction } ) => { - return ; + return ; }, }, service: { diff --git a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts index 2814168b81dfa..12889f6cc2223 100644 --- a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts +++ b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts @@ -1,9 +1,7 @@ import { useTranslate } from 'i18n-calypso'; -import moment from 'moment'; import { useMemo } from 'react'; import { BillingTransaction } from 'calypso/state/billing-transactions/types'; -import { DATE_FORMATS } from '../constants'; -import { groupDomainProducts } from '../utils'; +import { groupDomainProducts, formatDisplayDate, formatMonthYear } from '../utils'; import type { ViewState, Filter } from '../data-views-types'; export function useTransactionsFiltering( @@ -23,7 +21,7 @@ export function useTransactionsFiltering( transactionItem.product, transactionItem.variation, transactionItem.domain, - moment( transaction.date ).format( DATE_FORMATS.DISPLAY ), + formatDisplayDate( new Date( transaction.date ) ), ]; if ( @@ -47,8 +45,11 @@ export function useTransactionsFiltering( const [ firstItem ] = groupDomainProducts( transaction.items, translate ); return firstItem.type === filter.value; } - if ( filter.field === 'date' && filter.value ) { - return moment( transaction.date ).format( DATE_FORMATS.MONTH_YEAR ) === filter.value; + if ( filter.field === 'date' && filter.value && typeof filter.value === 'string' ) { + const [ year, month ] = filter.value.split( '-' ).map( Number ); + const filterDate = new Date( year, month - 1 ); + const transactionDate = new Date( transaction.date ); + return formatMonthYear( transactionDate ) === formatMonthYear( filterDate ); } return true; } ); diff --git a/client/me/purchases/billing-history/utils.tsx b/client/me/purchases/billing-history/utils.tsx index 0c435617853a2..9fe9383606680 100644 --- a/client/me/purchases/billing-history/utils.tsx +++ b/client/me/purchases/billing-history/utils.tsx @@ -342,3 +342,24 @@ export function getTransactionTermLabel( return getPlanTermLabel( transaction.wpcom_product_slug, translate ); } } + +export const formatDisplayDate = ( date: Date ): string => { + return date.toLocaleDateString( undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + } ); +}; + +export const formatMonthYearLabel = ( date: Date ): string => { + return date.toLocaleDateString( undefined, { + year: 'numeric', + month: 'long', + } ); +}; + +export const formatMonthYear = ( date: Date ): string => { + const year = date.getFullYear(); + const month = String( date.getMonth() + 1 ).padStart( 2, '0' ); + return `${ year }-${ month }`; +}; From 42678f8da2f0ddfad4d9fbbbfc907e13ed51cced Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 16:26:05 -0600 Subject: [PATCH 37/45] Refactor usePagination --- .../billing-history/hooks/use-pagination.ts | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-pagination.ts b/client/me/purchases/billing-history/hooks/use-pagination.ts index fba00fbbe47b2..b469a4d159593 100644 --- a/client/me/purchases/billing-history/hooks/use-pagination.ts +++ b/client/me/purchases/billing-history/hooks/use-pagination.ts @@ -1,20 +1,39 @@ import { useMemo } from 'react'; import type { BillingTransaction } from 'calypso/state/billing-transactions/types'; -export function usePagination( items: BillingTransaction[], page: number, perPage: number ) { - return useMemo( () => { - const startIndex = ( page - 1 ) * perPage; - const paginatedItems = items.slice( startIndex, startIndex + perPage ); - const totalItems = items.length; - const totalPages = Math.ceil( totalItems / perPage ); +interface PaginationResult { + paginatedItems: BillingTransaction[]; + totalPages: number; + totalItems: number; + currentPage: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +function calculatePagination( + items: BillingTransaction[], + page: number, + perPage: number +): PaginationResult { + const startIndex = ( page - 1 ) * perPage; + const paginatedItems = items.slice( startIndex, startIndex + perPage ); + const totalItems = items.length; + const totalPages = Math.ceil( totalItems / perPage ); + + return { + paginatedItems, + totalPages, + totalItems, + currentPage: page, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }; +} - return { - paginatedItems, - totalPages, - totalItems, - currentPage: page, - hasNextPage: page < totalPages, - hasPreviousPage: page > 1, - }; - }, [ items, page, perPage ] ); +export function usePagination( + items: BillingTransaction[], + page: number, + perPage: number +): PaginationResult { + return useMemo( () => calculatePagination( items, page, perPage ), [ items, page, perPage ] ); } From 86fa5c126ffb5e13a259fe180348ed21c0774ac2 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 16:31:09 -0600 Subject: [PATCH 38/45] Refactor useReceiptActions() to use top-level functions --- .../hooks/use-receipt-actions.ts | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-receipt-actions.ts b/client/me/purchases/billing-history/hooks/use-receipt-actions.ts index 680caa3ad3850..d17133826e876 100644 --- a/client/me/purchases/billing-history/hooks/use-receipt-actions.ts +++ b/client/me/purchases/billing-history/hooks/use-receipt-actions.ts @@ -9,9 +9,9 @@ import type { IAppState } from 'calypso/state/types'; import type { Action } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; -const recordClickEvent = ( eventAction: string ) => { +function recordClickEvent( eventAction: string ): void { recordGoogleEvent( 'Me', eventAction ); -}; +} export type ReceiptAction = { id: 'view-receipt' | 'email-receipt'; @@ -21,10 +21,30 @@ export type ReceiptAction = { callback: ( items: BillingTransaction[] ) => void; }; +type AppDispatch = ThunkDispatch< IAppState, undefined, Action >; + +function handleViewReceipt( + items: BillingTransaction[], + getReceiptUrlFor: ( receiptId: string ) => string +): void { + if ( ! items?.length || ! items[ 0 ]?.id ) { + return; + } + pageRedirect.redirect( getReceiptUrlFor( items[ 0 ].id ) ); +} + +function handleEmailReceipt( items: BillingTransaction[], dispatch: AppDispatch ): void { + if ( ! items?.length || ! items[ 0 ]?.id ) { + return; + } + recordClickEvent( 'Email Receipt in Billing History' ); + dispatch( sendBillingReceiptEmail( items[ 0 ].id ) ); +} + export function useReceiptActions( getReceiptUrlFor: ( receiptId: string ) => string ): ReceiptAction[] { - const dispatch = useDispatch< ThunkDispatch< IAppState, undefined, Action > >(); + const dispatch = useDispatch< AppDispatch >(); const translate = useTranslate(); return useMemo( @@ -34,33 +54,14 @@ export function useReceiptActions( label: translate( 'View receipt' ), isPrimary: true, iconName: 'pages', - callback: ( items: BillingTransaction[] ) => { - if ( ! items || ! Array.isArray( items ) ) { - return; - } - const item = items[ 0 ]; - if ( ! item?.id ) { - return; - } - pageRedirect.redirect( getReceiptUrlFor( item.id ) ); - }, + callback: ( items: BillingTransaction[] ) => handleViewReceipt( items, getReceiptUrlFor ), }, { id: 'email-receipt', label: translate( 'Email receipt' ), isPrimary: true, iconName: 'mail', - callback: ( items: BillingTransaction[] ) => { - if ( ! items || ! Array.isArray( items ) ) { - return; - } - const item = items[ 0 ]; - if ( ! item?.id ) { - return; - } - recordClickEvent( 'Email Receipt in Billing History' ); - dispatch( sendBillingReceiptEmail( item.id ) ); - }, + callback: ( items: BillingTransaction[] ) => handleEmailReceipt( items, dispatch ), }, ], [ dispatch, getReceiptUrlFor, translate ] From 6c70e4ff44b28fd10f0c00af87b1c0d8a0a3e53a Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 16:32:09 -0600 Subject: [PATCH 39/45] Refactor ususeViewStateUpdate() to use top-level functions --- .../hooks/use-view-state-update.ts | 158 +++++++++++------- 1 file changed, 96 insertions(+), 62 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-view-state-update.ts b/client/me/purchases/billing-history/hooks/use-view-state-update.ts index 5dba349e65c07..0adf9a4d976dc 100644 --- a/client/me/purchases/billing-history/hooks/use-view-state-update.ts +++ b/client/me/purchases/billing-history/hooks/use-view-state-update.ts @@ -11,11 +11,17 @@ type Sort = type Filters = undefined | Filter[]; +interface ViewStateUpdateResult { + view: ViewState; + updateView: ( newView: ViewStateUpdate ) => void; +} + +function scrollToTop(): void { + window.scrollTo( { top: 0, behavior: 'smooth' } ); +} + function verifySortField( field: string ): field is SortableField { - if ( ! [ 'date', 'service', 'type', 'amount' ].includes( field ) ) { - return false; - } - return true; + return [ 'date', 'service', 'type', 'amount' ].includes( field ); } function areSortsEqual( a: Sort, b: Sort ): boolean { @@ -46,67 +52,95 @@ function areFiltersEqual( a: Filters, b: Filters ): boolean { ); } -export function useViewStateUpdate() { +function handlePageUpdate( updatedView: ViewState, newView: ViewStateUpdate ): void { + if ( newView.page !== undefined ) { + updatedView.page = newView.page; + scrollToTop(); + } +} + +function handlePerPageUpdate( + updatedView: ViewState, + currentView: ViewState, + newView: ViewStateUpdate +): void { + if ( newView.perPage !== undefined && newView.perPage !== currentView.perPage ) { + updatedView.perPage = newView.perPage; + updatedView.page = 1; + scrollToTop(); + } +} + +function handleSortUpdate( + updatedView: ViewState, + currentView: ViewState, + newView: ViewStateUpdate +): void { + if ( newView.sort && ! areSortsEqual( newView.sort, currentView.sort ) ) { + if ( verifySortField( newView.sort.field ) ) { + updatedView.sort = { + field: newView.sort.field, + direction: newView.sort.direction, + }; + if ( newView.page === undefined ) { + updatedView.page = 1; + scrollToTop(); + } + } + } +} + +function handleFiltersUpdate( + updatedView: ViewState, + currentView: ViewState, + newView: ViewStateUpdate +): void { + if ( newView.filters && ! areFiltersEqual( newView.filters, currentView.filters ) ) { + updatedView.filters = newView.filters; + if ( newView.page === undefined ) { + updatedView.page = 1; + scrollToTop(); + } + } +} + +function handleSearchUpdate( + updatedView: ViewState, + currentView: ViewState, + newView: ViewStateUpdate +): void { + if ( newView.search !== undefined && newView.search !== currentView.search ) { + updatedView.search = newView.search; + if ( newView.page === undefined ) { + updatedView.page = 1; + scrollToTop(); + } + } +} + +function handleFieldsUpdate( updatedView: ViewState, newView: ViewStateUpdate ): void { + if ( newView.fields !== undefined ) { + updatedView.fields = newView.fields; + } +} + +export function useViewStateUpdate(): ViewStateUpdateResult { const [ view, setView ] = useState< ViewState >( defaultDataViewsState ); - const scrollToTop = useCallback( () => { - window.scrollTo( { top: 0, behavior: 'smooth' } ); - }, [] ); + const updateView = useCallback( ( newView: ViewStateUpdate ) => { + setView( ( currentView ) => { + const updatedView = { ...currentView }; - const updateView = useCallback( - ( newView: ViewStateUpdate ) => { - setView( ( currentView ) => { - const updatedView = { ...currentView }; - - if ( newView.page !== undefined ) { - updatedView.page = newView.page; - scrollToTop(); - } - - if ( newView.perPage !== undefined && newView.perPage !== currentView.perPage ) { - updatedView.perPage = newView.perPage; - updatedView.page = 1; - scrollToTop(); - } - - if ( newView.sort && ! areSortsEqual( newView.sort, currentView.sort ) ) { - if ( verifySortField( newView.sort.field ) ) { - updatedView.sort = { - field: newView.sort.field, - direction: newView.sort.direction, - }; - if ( newView.page === undefined ) { - updatedView.page = 1; - scrollToTop(); - } - } - } - - if ( newView.filters && ! areFiltersEqual( newView.filters, currentView.filters ) ) { - updatedView.filters = newView.filters; - if ( newView.page === undefined ) { - updatedView.page = 1; - scrollToTop(); - } - } - - if ( newView.search !== undefined && newView.search !== currentView.search ) { - updatedView.search = newView.search; - if ( newView.page === undefined ) { - updatedView.page = 1; - scrollToTop(); - } - } - - if ( newView.fields !== undefined ) { - updatedView.fields = newView.fields; - } - - return updatedView; - } ); - }, - [ scrollToTop ] - ); + handlePageUpdate( updatedView, newView ); + handlePerPageUpdate( updatedView, currentView, newView ); + handleSortUpdate( updatedView, currentView, newView ); + handleFiltersUpdate( updatedView, currentView, newView ); + handleSearchUpdate( updatedView, currentView, newView ); + handleFieldsUpdate( updatedView, newView ); + + return updatedView; + } ); + }, [] ); return { view, From 35885578862ee6c24fbfbef57dabad002cfa1f12 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 16:32:56 -0600 Subject: [PATCH 40/45] Refactor useTransactionsFiltering() to use top-level functions --- .../hooks/use-transactions-filtering.ts | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts index 12889f6cc2223..27c9c70cafd19 100644 --- a/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts +++ b/client/me/purchases/billing-history/hooks/use-transactions-filtering.ts @@ -4,6 +4,46 @@ import { BillingTransaction } from 'calypso/state/billing-transactions/types'; import { groupDomainProducts, formatDisplayDate, formatMonthYear } from '../utils'; import type { ViewState, Filter } from '../data-views-types'; +function matchesSearch( + transaction: BillingTransaction, + searchTerm: string, + translate: ReturnType< typeof useTranslate > +) { + const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); + const searchableFields = [ + transaction.service, + transactionItem.product, + transactionItem.variation, + transactionItem.domain, + formatDisplayDate( new Date( transaction.date ) ), + ]; + + return searchableFields.some( + ( field ) => field && field.toString().toLowerCase().includes( searchTerm.toLowerCase() ) + ); +} + +function matchesFilter( + transaction: BillingTransaction, + filter: Filter, + translate: ReturnType< typeof useTranslate > +) { + if ( filter.field === 'service' && filter.value ) { + return transaction.service === filter.value; + } + if ( filter.field === 'type' && filter.value ) { + const [ firstItem ] = groupDomainProducts( transaction.items, translate ); + return firstItem.type === filter.value; + } + if ( filter.field === 'date' && filter.value && typeof filter.value === 'string' ) { + const [ year, month ] = filter.value.split( '-' ).map( Number ); + const filterDate = new Date( year, month - 1 ); + const transactionDate = new Date( transaction.date ); + return formatMonthYear( transactionDate ) === formatMonthYear( filterDate ); + } + return true; +} + export function useTransactionsFiltering( transactions: BillingTransaction[] | null, view: ViewState @@ -12,47 +52,15 @@ export function useTransactionsFiltering( return useMemo( () => { return ( transactions ?? [] ).filter( ( transaction ) => { - if ( view.search ) { - const searchTerm = view.search.toLowerCase(); - const [ transactionItem ] = groupDomainProducts( transaction.items, translate ); - - const searchableFields = [ - transaction.service, - transactionItem.product, - transactionItem.variation, - transactionItem.domain, - formatDisplayDate( new Date( transaction.date ) ), - ]; - - if ( - ! searchableFields.some( - ( field ) => field && field.toString().toLowerCase().includes( searchTerm ) - ) - ) { - return false; - } + if ( view.search && ! matchesSearch( transaction, view.search, translate ) ) { + return false; } if ( view.filters.length === 0 ) { return true; } - return view.filters.every( ( filter: Filter ) => { - if ( filter.field === 'service' && filter.value ) { - return transaction.service === filter.value; - } - if ( filter.field === 'type' && filter.value ) { - const [ firstItem ] = groupDomainProducts( transaction.items, translate ); - return firstItem.type === filter.value; - } - if ( filter.field === 'date' && filter.value && typeof filter.value === 'string' ) { - const [ year, month ] = filter.value.split( '-' ).map( Number ); - const filterDate = new Date( year, month - 1 ); - const transactionDate = new Date( transaction.date ); - return formatMonthYear( transactionDate ) === formatMonthYear( filterDate ); - } - return true; - } ); + return view.filters.every( ( filter ) => matchesFilter( transaction, filter, translate ) ); } ); }, [ transactions, view.search, view.filters, translate ] ); } From 9b06f3618fad127c7123cbe49297114b10d39809 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 16:33:30 -0600 Subject: [PATCH 41/45] Refactor useTransactionsSorting() to use top-level functions --- .../hooks/use-transactions-sorting.ts | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/client/me/purchases/billing-history/hooks/use-transactions-sorting.ts b/client/me/purchases/billing-history/hooks/use-transactions-sorting.ts index f1201b1ef447d..66d4b10219cf3 100644 --- a/client/me/purchases/billing-history/hooks/use-transactions-sorting.ts +++ b/client/me/purchases/billing-history/hooks/use-transactions-sorting.ts @@ -1,35 +1,36 @@ import { useMemo } from 'react'; import { BillingTransaction } from 'calypso/state/billing-transactions/types'; -import type { ViewState } from '../data-views-types'; +import type { ViewState, SortableField } from '../data-views-types'; + +function compareTransactions( + a: BillingTransaction, + b: BillingTransaction, + sortField: SortableField +): number { + switch ( sortField ) { + case 'date': + return new Date( a.date ).getTime() - new Date( b.date ).getTime(); + case 'service': { + const aService = a.items.length > 0 ? a.items[ 0 ].variation : a.service; + const bService = b.items.length > 0 ? b.items[ 0 ].variation : b.service; + return ( aService || '' ).localeCompare( bService || '' ); + } + case 'type': { + const aType = a.items.length > 0 ? a.items[ 0 ].type : ''; + const bType = b.items.length > 0 ? b.items[ 0 ].type : ''; + return ( aType || '' ).localeCompare( bType || '' ); + } + case 'amount': + return a.amount_integer - b.amount_integer; + default: + return 0; + } +} export function useTransactionsSorting( transactions: BillingTransaction[], view: ViewState ) { return useMemo( () => { return [ ...transactions ].sort( ( a, b ) => { - let comparison = 0; - const sortField = view.sort.field; - - switch ( sortField ) { - case 'date': - comparison = new Date( a.date ).getTime() - new Date( b.date ).getTime(); - break; - case 'service': { - const aService = a.items.length > 0 ? a.items[ 0 ].variation : a.service; - const bService = b.items.length > 0 ? b.items[ 0 ].variation : b.service; - comparison = ( aService || '' ).localeCompare( bService || '' ); - break; - } - case 'type': { - const aType = a.items.length > 0 ? a.items[ 0 ].type : ''; - const bType = b.items.length > 0 ? b.items[ 0 ].type : ''; - comparison = ( aType || '' ).localeCompare( bType || '' ); - break; - } - case 'amount': - comparison = a.amount_integer - b.amount_integer; - break; - default: - return 0; - } + const comparison = compareTransactions( a, b, view.sort.field ); return view.sort.direction === 'desc' ? -comparison : comparison; } ); }, [ transactions, view.sort ] ); From ad6a7bdbce3a5b2991bd63ab1d3bd6b3c746362a Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 16:47:35 -0600 Subject: [PATCH 42/45] Move the defaultLayout outside of the BillingHistoryListDataView function --- .../billing-history/billing-history-list-data-view.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index cdc2727e424f2..7e0b00313467b 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -14,6 +14,8 @@ import { useViewStateUpdate } from './hooks/use-view-state-update'; import 'calypso/components/dataviews/style.scss'; import './style-data-view.scss'; +const DEFAULT_LAYOUT = { table: {} }; + export interface BillingHistoryListProps { getReceiptUrlFor: ( receiptId: string ) => string; } @@ -40,7 +42,6 @@ export default function BillingHistoryListDataView( { ); const translate = useTranslate(); const fields = useFieldDefinitions( transactions ); - const defaultLayout = { table: {} }; return (
    @@ -56,7 +57,7 @@ export default function BillingHistoryListDataView( { search searchLabel={ translate( 'Search receipts' ) } onChangeView={ viewState.updateView } - defaultLayouts={ defaultLayout } + defaultLayouts={ DEFAULT_LAYOUT } actions={ actions } isLoading={ isLoading } /> From 1e26d0221eae9dc01355c2c12e5483ade3266cb6 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Mon, 13 Jan 2025 17:09:22 -0600 Subject: [PATCH 43/45] Add useMemo() to useViewStateUpdate() --- .../billing-history-list-data-view.tsx | 7 ++++-- .../billing-history/data-views-types.ts | 15 ++++++------ .../hooks/use-view-state-update.ts | 23 ++++++++----------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/client/me/purchases/billing-history/billing-history-list-data-view.tsx b/client/me/purchases/billing-history/billing-history-list-data-view.tsx index 7e0b00313467b..ee856657a03ae 100644 --- a/client/me/purchases/billing-history/billing-history-list-data-view.tsx +++ b/client/me/purchases/billing-history/billing-history-list-data-view.tsx @@ -1,5 +1,5 @@ import { Gridicon } from '@automattic/components'; -import { DataViews } from '@wordpress/dataviews'; +import { DataViews, View } from '@wordpress/dataviews'; import { useTranslate } from 'i18n-calypso'; import { useSelector } from 'react-redux'; import getPastBillingTransactions from 'calypso/state/selectors/get-past-billing-transactions'; @@ -10,6 +10,7 @@ import { useReceiptActions } from './hooks/use-receipt-actions'; import { useTransactionsFiltering } from './hooks/use-transactions-filtering'; import { useTransactionsSorting } from './hooks/use-transactions-sorting'; import { useViewStateUpdate } from './hooks/use-view-state-update'; +import type { ViewStateUpdate } from './data-views-types'; import 'calypso/components/dataviews/style.scss'; import './style-data-view.scss'; @@ -43,6 +44,8 @@ export default function BillingHistoryListDataView( { const translate = useTranslate(); const fields = useFieldDefinitions( transactions ); + const handleViewChange = ( view: View ) => viewState.updateView( view as ViewStateUpdate ); + return (
    @@ -56,7 +59,7 @@ export default function BillingHistoryListDataView( { view={ viewState.view } search searchLabel={ translate( 'Search receipts' ) } - onChangeView={ viewState.updateView } + onChangeView={ handleViewChange } defaultLayouts={ DEFAULT_LAYOUT } actions={ actions } isLoading={ isLoading } diff --git a/client/me/purchases/billing-history/data-views-types.ts b/client/me/purchases/billing-history/data-views-types.ts index a31ef86462c26..4031c1c509c71 100644 --- a/client/me/purchases/billing-history/data-views-types.ts +++ b/client/me/purchases/billing-history/data-views-types.ts @@ -4,6 +4,11 @@ export type SortableField = 'date' | 'service' | 'type' | 'amount'; export type ViewType = 'table'; export type SortDirection = 'asc' | 'desc'; +export interface Sort { + field: SortableField; + direction: SortDirection; +} + export interface Filter { field: string; operator: Operator; @@ -13,10 +18,7 @@ export interface Filter { export interface ViewStateUpdate { page?: number; perPage?: number; - sort?: { - field: string; - direction: SortDirection; - }; + sort?: Sort; filters?: Filter[]; search?: string; fields?: string[]; @@ -28,10 +30,7 @@ export interface ViewState { filters: Filter[]; page: number; perPage: number; - sort: { - field: SortableField; - direction: SortDirection; - }; + sort: Sort; fields: string[]; hiddenFields: string[]; layout?: { diff --git a/client/me/purchases/billing-history/hooks/use-view-state-update.ts b/client/me/purchases/billing-history/hooks/use-view-state-update.ts index 0adf9a4d976dc..07a07eba69555 100644 --- a/client/me/purchases/billing-history/hooks/use-view-state-update.ts +++ b/client/me/purchases/billing-history/hooks/use-view-state-update.ts @@ -1,13 +1,6 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { defaultDataViewsState } from '../constants'; -import type { ViewState, ViewStateUpdate, SortableField, Filter } from '../data-views-types'; - -type Sort = - | undefined - | { - field: string; - direction: 'asc' | 'desc'; - }; +import type { ViewState, ViewStateUpdate, SortableField, Filter, Sort } from '../data-views-types'; type Filters = undefined | Filter[]; @@ -24,7 +17,7 @@ function verifySortField( field: string ): field is SortableField { return [ 'date', 'service', 'type', 'amount' ].includes( field ); } -function areSortsEqual( a: Sort, b: Sort ): boolean { +function areSortsEqual( a: Sort | undefined, b: Sort | undefined ): boolean { if ( a?.field !== b?.field ) { return false; } @@ -142,8 +135,10 @@ export function useViewStateUpdate(): ViewStateUpdateResult { } ); }, [] ); - return { - view, - updateView, - }; + return useMemo( () => { + return { + view, + updateView, + }; + }, [ view, updateView ] ); } From 88f12fb602cbbc1bd9d929c0925969d224aed216 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Tue, 14 Jan 2025 13:12:30 -0600 Subject: [PATCH 44/45] Convert arrow functions in field-definitions.tsx --- .../billing-history/field-definitions.tsx | 194 +++++++++--------- 1 file changed, 98 insertions(+), 96 deletions(-) diff --git a/client/me/purchases/billing-history/field-definitions.tsx b/client/me/purchases/billing-history/field-definitions.tsx index 5c3fa406c7625..c5828ed2ce432 100644 --- a/client/me/purchases/billing-history/field-definitions.tsx +++ b/client/me/purchases/billing-history/field-definitions.tsx @@ -15,10 +15,10 @@ import type { BillingTransactionItem, } from 'calypso/state/billing-transactions/types'; -const serviceNameDescription = ( +function renderServiceNameDescription( transaction: BillingTransactionItem, translate: ReturnType< typeof useTranslate > -) => { +) { const plan = capitalPDangit( transaction.variation ); const termLabel = getTransactionTermLabel( transaction, translate ); return ( @@ -31,12 +31,12 @@ const serviceNameDescription = ( ) }
    ); -}; +} -const serviceName = ( +function renderServiceName( transaction: BillingTransaction, translate: ReturnType< typeof useTranslate > -) => { +) { const [ transactionItem, ...moreTransactionItems ] = groupDomainProducts( transaction.items, translate @@ -50,12 +50,12 @@ const serviceName = ( return transactionItem.product; } - return serviceNameDescription( transactionItem, translate ); -}; + return renderServiceNameDescription( transactionItem, translate ); +} -const getUniqueMonths = ( +function getUniqueMonths( transactions: BillingTransaction[] -): Array< { value: string; label: string } > => { +): Array< { value: string; label: string } > { const monthsMap = new Map< string, Date >(); transactions.forEach( ( transaction ) => { @@ -70,11 +70,11 @@ const getUniqueMonths = ( value, label: formatMonthYearLabel( date ), } ) ); -}; +} -const getUniqueServices = ( +function getUniqueServices( transactions: BillingTransaction[] -): Array< { value: string; label: string } > => { +): Array< { value: string; label: string } > { const uniqueServices = new Set( transactions.map( ( transaction ) => transaction.service ) ); return Array.from( uniqueServices ) @@ -83,11 +83,11 @@ const getUniqueServices = ( value: service, label: service, } ) ); -}; +} -const getUniqueTransactionTypes = ( +function getUniqueTransactionTypes( transactions: BillingTransaction[] -): Array< { value: string; label: string } > => { +): Array< { value: string; label: string } > { const typeMap = new Map< string, string >(); transactions @@ -104,91 +104,93 @@ const getUniqueTransactionTypes = ( value, label, } ) ); -}; +} -export const getFieldDefinitions = ( +export function getFieldDefinitions( transactions: BillingTransaction[] | null, translate: ReturnType< typeof useTranslate > -) => ( { - date: { - id: 'date', - label: translate( 'Date' ), - type: 'text' as const, - width: '15%', - elements: getUniqueMonths( transactions ?? [] ), - enableGlobalSearch: true, - enableSorting: true, - enableHiding: false, - filterBy: { - operators: [ 'is' as Operator ], +) { + return { + date: { + id: 'date', + label: translate( 'Date' ), + type: 'text' as const, + width: '15%', + elements: getUniqueMonths( transactions ?? [] ), + enableGlobalSearch: true, + enableSorting: true, + enableHiding: false, + filterBy: { + operators: [ 'is' as Operator ], + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + return formatMonthYear( new Date( item.date ) ); + }, + render: ( { item }: { item: BillingTransaction } ) => { + return ; + }, }, - getValue: ( { item }: { item: BillingTransaction } ) => { - return formatMonthYear( new Date( item.date ) ); + service: { + id: 'service', + label: translate( 'App' ), + type: 'text' as const, + width: '45%', + elements: getUniqueServices( transactions ?? [] ), + enableGlobalSearch: true, + enableSorting: true, + enableHiding: false, + filterBy: { + operators: [ 'is' as Operator ], + }, + render: ( { item }: { item: BillingTransaction } ) => { + return
    { renderServiceName( item, translate ) }
    ; + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + if ( transactionItem.product === transactionItem.variation ) { + return transactionItem.product; + } + return capitalPDangit( transactionItem.variation ); + }, }, - render: ( { item }: { item: BillingTransaction } ) => { - return ; + type: { + id: 'type', + label: translate( 'Type' ), + type: 'text' as const, + width: '20%', + elements: getUniqueTransactionTypes( transactions ?? [] ), + enableGlobalSearch: true, + enableSorting: true, + enableHiding: false, + filterBy: { + operators: [ 'is' as Operator ], + }, + render: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return
    { transactionItem.type_localized || transactionItem.type }
    ; + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + const [ transactionItem ] = groupDomainProducts( item.items, translate ); + return transactionItem.type; + }, }, - }, - service: { - id: 'service', - label: translate( 'App' ), - type: 'text' as const, - width: '45%', - elements: getUniqueServices( transactions ?? [] ), - enableGlobalSearch: true, - enableSorting: true, - enableHiding: false, - filterBy: { - operators: [ 'is' as Operator ], + amount: { + id: 'amount', + label: translate( 'Amount' ), + type: 'text' as const, + width: '20%', + enableGlobalSearch: true, + enableSorting: true, + enableHiding: false, + filterBy: { + operators: [ 'is' as Operator ], + }, + getValue: ( { item }: { item: BillingTransaction } ) => { + return item.amount_integer; + }, + render: ( { item }: { item: BillingTransaction } ) => { + return ; + }, }, - render: ( { item }: { item: BillingTransaction } ) => { - return
    { serviceName( item, translate ) }
    ; - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - if ( transactionItem.product === transactionItem.variation ) { - return transactionItem.product; - } - return capitalPDangit( transactionItem.variation ); - }, - }, - type: { - id: 'type', - label: translate( 'Type' ), - type: 'text' as const, - width: '20%', - elements: getUniqueTransactionTypes( transactions ?? [] ), - enableGlobalSearch: true, - enableSorting: true, - enableHiding: false, - filterBy: { - operators: [ 'is' as Operator ], - }, - render: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - return
    { transactionItem.type_localized || transactionItem.type }
    ; - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - const [ transactionItem ] = groupDomainProducts( item.items, translate ); - return transactionItem.type; - }, - }, - amount: { - id: 'amount', - label: translate( 'Amount' ), - type: 'text' as const, - width: '20%', - enableGlobalSearch: true, - enableSorting: true, - enableHiding: false, - filterBy: { - operators: [ 'is' as Operator ], - }, - getValue: ( { item }: { item: BillingTransaction } ) => { - return item.amount_integer; - }, - render: ( { item }: { item: BillingTransaction } ) => { - return ; - }, - }, -} ); + }; +} From b34e43272df98ea265edcfdfe5994c9c22d64a28 Mon Sep 17 00:00:00 2001 From: Bill Robbins Date: Tue, 14 Jan 2025 13:15:57 -0600 Subject: [PATCH 45/45] Convert arrow functions in utils.tsx --- client/me/purchases/billing-history/utils.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/me/purchases/billing-history/utils.tsx b/client/me/purchases/billing-history/utils.tsx index 9fe9383606680..3153580db755a 100644 --- a/client/me/purchases/billing-history/utils.tsx +++ b/client/me/purchases/billing-history/utils.tsx @@ -343,23 +343,23 @@ export function getTransactionTermLabel( } } -export const formatDisplayDate = ( date: Date ): string => { +export function formatDisplayDate( date: Date ): string { return date.toLocaleDateString( undefined, { year: 'numeric', month: 'short', day: 'numeric', } ); -}; +} -export const formatMonthYearLabel = ( date: Date ): string => { +export function formatMonthYearLabel( date: Date ): string { return date.toLocaleDateString( undefined, { year: 'numeric', month: 'long', } ); -}; +} -export const formatMonthYear = ( date: Date ): string => { +export function formatMonthYear( date: Date ): string { const year = date.getFullYear(); const month = String( date.getMonth() + 1 ).padStart( 2, '0' ); return `${ year }-${ month }`; -}; +}