diff --git a/changelog/refactor-server-export-sync-download b/changelog/refactor-server-export-sync-download new file mode 100644 index 00000000000..eaff6dbbf0c --- /dev/null +++ b/changelog/refactor-server-export-sync-download @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Change Transactions sync download to use server export. diff --git a/client/data/transactions/resolvers.js b/client/data/transactions/resolvers.js index 54d96fb7127..9bcefa2ce56 100644 --- a/client/data/transactions/resolvers.js +++ b/client/data/transactions/resolvers.js @@ -89,10 +89,10 @@ export function* getTransactions( query ) { } export function getTransactionsCSV( query ) { - const path = addQueryArgs( - `${ NAMESPACE }/transactions/download`, - formatQueryFilters( query ) - ); + const path = addQueryArgs( `${ NAMESPACE }/transactions/download`, { + ...formatQueryFilters( query ), + download_type: query.downloadType, + } ); return path; } diff --git a/client/transactions/list/index.tsx b/client/transactions/list/index.tsx index f33586e26f5..c1735c6c9ba 100644 --- a/client/transactions/list/index.tsx +++ b/client/transactions/list/index.tsx @@ -7,6 +7,7 @@ import React, { Fragment, useState } from 'react'; import { uniq } from 'lodash'; import { useDispatch } from '@wordpress/data'; import { __, _n, sprintf } from '@wordpress/i18n'; + import { TableCard, Search, @@ -18,11 +19,6 @@ import { getQuery, updateQueryString, } from '@woocommerce/navigation'; -import { - downloadCSVFile, - generateCSVDataFromTable, - generateCSVFileName, -} from '@woocommerce/csv-export'; import apiFetch from '@wordpress/api-fetch'; /** @@ -84,6 +80,11 @@ interface Column extends TableCardColumn { labelInCsv?: string; } +interface TransactionExportResponse { + download_url?: string; + exported_transactions: number; +} + const getPaymentSourceDetails = ( txn: Transaction ) => { if ( ! txn.source_identifier ) { return ; @@ -580,6 +581,7 @@ export const TransactionsList = ( const downloadable = !! rows.length; const endpointExport = async () => { + const downloadType = totalRows > rows.length ? 'async' : 'sync'; // We destructure page and path to get the right params. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { page, path, ...params } = getQuery(); @@ -642,7 +644,7 @@ export const TransactionsList = ( window.confirm( confirmMessage ) ) { try { - await apiFetch( { + const response = await apiFetch< TransactionExportResponse >( { path: getTransactionsCSV( { userEmail, userLocale, @@ -666,20 +668,29 @@ export const TransactionsList = ( riskLevelIs, riskLevelIsNot, depositId, + downloadType, } ), method: 'POST', } ); - createNotice( - 'success', - sprintf( - __( - 'Your export will be emailed to %s', - 'woocommerce-payments' - ), - userEmail - ) - ); + if ( response?.download_url ) { + const link = document.createElement( 'a' ); + // Add force_download=true to the URL to force the download, which adds the appropriate `Content-Disposition: attachment` header when using production server. + link.href = response.download_url + '?force_download=true'; + link.click(); + } else { + // Show email notification if no direct download URL + createNotice( + 'success', + sprintf( + __( + 'Your export will be emailed to %s', + 'woocommerce-payments' + ), + userEmail + ) + ); + } } catch { createNotice( 'error', @@ -698,29 +709,14 @@ export const TransactionsList = ( // We destructure page and path to get the right params. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { page, path, ...params } = getQuery(); - const downloadType = totalRows > rows.length ? 'endpoint' : 'browser'; recordEvent( 'wcpay_transactions_download_csv_click', { location: props.depositId ? 'deposit_details' : 'transactions', - download_type: downloadType, exported_transactions: rows.length, total_transactions: transactionsSummary.count, } ); - if ( 'endpoint' === downloadType ) { - endpointExport(); - } else { - const columnsToDisplayInCsv = columnsToDisplay.map( ( column ) => { - if ( column.labelInCsv ) { - return { ...column, label: column.labelInCsv }; - } - return column; - } ); - downloadCSVFile( - generateCSVFileName( title, params ), - generateCSVDataFromTable( columnsToDisplayInCsv, rows ) - ); - } + endpointExport(); setIsDownloading( false ); }; diff --git a/client/transactions/list/test/index.tsx b/client/transactions/list/test/index.tsx index 5d4efbf827e..ea38361a826 100644 --- a/client/transactions/list/test/index.tsx +++ b/client/transactions/list/test/index.tsx @@ -7,13 +7,9 @@ import * as React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import user from '@testing-library/user-event'; import apiFetch from '@wordpress/api-fetch'; -import { dateI18n } from '@wordpress/date'; -import { downloadCSVFile } from '@woocommerce/csv-export'; import { getQuery, updateQueryString } from '@woocommerce/navigation'; import { useUserPreferences } from '@woocommerce/data'; import { getUserTimeZone } from 'wcpay/utils/test-utils'; -import moment from 'moment'; -import os from 'os'; /** * Internal dependencies @@ -72,10 +68,6 @@ jest.mock( '@wordpress/date', () => ( { } ), } ) ); -const mockDownloadCSVFile = downloadCSVFile as jest.MockedFunction< - typeof downloadCSVFile ->; - const mockApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >; const mockUseTransactions = useTransactions as jest.MockedFunction< @@ -213,18 +205,6 @@ const getMockTransactions: () => Transaction[] = () => [ }, ]; -function getUnformattedAmount( formattedAmount: string ) { - const amount = formattedAmount.replace( /[^0-9,.' ]/g, '' ).trim(); - return amount.replace( ',', '.' ); // Euro fix -} - -function formatDate( date: string ) { - return dateI18n( - 'M j, Y / g:iA', - moment.utc( date ).local().toISOString() - ); -} - describe( 'Transactions list', () => { beforeEach( () => { jest.clearAllMocks(); @@ -664,115 +644,33 @@ describe( 'Transactions list', () => { getByRole( 'button', { name: 'Download' } ).click(); + // Check if the API request is made with the correct download type = 'async'. await waitFor( () => { expect( mockApiFetch ).toHaveBeenCalledTimes( 1 ); expect( mockApiFetch ).toHaveBeenCalledWith( { method: 'POST', path: `/wc/v3/payments/transactions/download?user_email=mock%40example.com&deposit_id=po_mock&user_timezone=${ encodeURIComponent( getUserTimeZone() - ) }&locale=en`, + ) }&locale=en&download_type=async`, } ); } ); } ); - test( 'should render expected columns in CSV when the download button is clicked', () => { + test( 'should fetch export when number of transactions is less than 100', async () => { const { getByRole } = render( ); getByRole( 'button', { name: 'Download' } ).click(); - const expected = [ - '"Transaction ID"', - '"Date / Time (UTC)"', - 'Type', - 'Channel', - '"Paid Currency"', - '"Amount Paid"', - '"Payout Currency"', - 'Amount', - 'Fees', - 'Net', - '"Order #"', - '"Payment Method"', - 'Customer', - 'Email', - 'Country', - '"Risk level"', - '"Payout ID"', - '"Payout date"', - '"Payout status"', - ]; - - // checking if columns in CSV are rendered correctly - expect( - mockDownloadCSVFile.mock.calls[ 0 ][ 1 ] - .split( '\n' )[ 0 ] - .split( ',' ) - ).toEqual( expected ); - } ); - - test( 'should match the visible rows', () => { - const { getByRole, getAllByRole } = render( ); - - getByRole( 'button', { name: 'Download' } ).click(); - - const csvContent = mockDownloadCSVFile.mock.calls[ 0 ][ 1 ]; - const csvRows = csvContent.split( os.EOL ); - const displayRows: HTMLElement[] = getAllByRole( 'row' ); - - expect( csvRows.length ).toEqual( displayRows.length ); - - const csvFirstTransaction = csvRows[ 1 ].split( ',' ); - const displayFirstTransaction: string[] = Array.from( - displayRows[ 1 ].querySelectorAll( 'td' ) - ).map( ( td: HTMLElement ) => td.textContent || '' ); - - // Date/Time column is a th - // Extract is separately and prepend to csvFirstTransaction - const displayFirstRowHead: string[] = Array.from( - displayRows[ 1 ].querySelectorAll( 'th' ) - ).map( ( th: HTMLElement ) => th.textContent || '' ); - displayFirstTransaction.unshift( displayFirstRowHead[ 0 ] ); - - // Note: - // - // 1. CSV and display indexes are off by 1 because the first field in CSV is transaction id, - // which is missing in display. - // - // 2. The indexOf check in amount's expect is because the amount in CSV may not contain - // trailing zeros as in the display amount. - // - expect( displayFirstTransaction[ 0 ] ).toBe( - formatDate( csvFirstTransaction[ 1 ].replace( /['"]+/g, '' ) ) // strip extra quotes - ); // date - expect( displayFirstTransaction[ 1 ] ).toBe( - csvFirstTransaction[ 2 ] - ); // type - expect( displayFirstTransaction[ 2 ] ).toBe( - csvFirstTransaction[ 3 ] - ); // channel - expect( - getUnformattedAmount( displayFirstTransaction[ 3 ] ).indexOf( - csvFirstTransaction[ 7 ] - ) - ).not.toBe( -1 ); // amount - expect( - -Number( getUnformattedAmount( displayFirstTransaction[ 4 ] ) ) - ).toEqual( - Number( - csvFirstTransaction[ 8 ].replace( /['"]+/g, '' ) // strip extra quotes - ) - ); // fees - expect( - getUnformattedAmount( displayFirstTransaction[ 5 ] ).indexOf( - csvFirstTransaction[ 9 ] - ) - ).not.toBe( -1 ); // net - expect( displayFirstTransaction[ 6 ] ).toBe( - csvFirstTransaction[ 10 ] - ); // order number - expect( displayFirstTransaction[ 8 ] ).toBe( - csvFirstTransaction[ 12 ].replace( /['"]+/g, '' ) // strip extra quotes - ); // customer + // Check if the API request is made with the correct download type = 'sync'. + await waitFor( () => { + expect( mockApiFetch ).toHaveBeenCalledTimes( 1 ); + expect( mockApiFetch ).toHaveBeenCalledWith( { + method: 'POST', + path: `/wc/v3/payments/transactions/download?user_email=mock%40example.com&user_timezone=${ encodeURIComponent( + getUserTimeZone() + ) }&locale=en&download_type=sync`, + } ); + } ); } ); } ); } ); diff --git a/includes/admin/class-wc-rest-payments-transactions-controller.php b/includes/admin/class-wc-rest-payments-transactions-controller.php index 9e7c10b76a4..5eb7f7fe635 100644 --- a/includes/admin/class-wc-rest-payments-transactions-controller.php +++ b/includes/admin/class-wc-rest-payments-transactions-controller.php @@ -162,12 +162,13 @@ public function get_fraud_outcome_transactions_export( $request ) { * @param WP_REST_Request $request Full data about the request. */ public function get_transactions_export( $request ) { - $user_email = $request->get_param( 'user_email' ); - $deposit_id = $request->get_param( 'deposit_id' ); - $locale = $request->get_param( 'locale' ); - $filters = $this->get_transactions_filters( $request ); + $user_email = $request->get_param( 'user_email' ); + $deposit_id = $request->get_param( 'deposit_id' ); + $locale = $request->get_param( 'locale' ); + $download_type = $request->get_param( 'download_type' ); + $filters = $this->get_transactions_filters( $request ); - return $this->forward_request( 'get_transactions_export', [ $filters, $user_email, $deposit_id, $locale ] ); + return $this->forward_request( 'get_transactions_export', [ $filters, $user_email, $deposit_id, $locale, $download_type ] ); } /** diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index e90094d57de..e40fea5b964 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -432,12 +432,13 @@ public function get_fraud_outcome_transactions_export( $request ) { * @param string $user_email The email to search for. * @param string $deposit_id The deposit to filter on. * @param string $locale Site locale. + * @param string $download_type The type of download to perform. * * @return array Export summary * * @throws API_Exception - Exception thrown on request failure. */ - public function get_transactions_export( $filters = [], $user_email = '', $deposit_id = null, $locale = null ) { + public function get_transactions_export( $filters = [], $user_email = '', $deposit_id = null, $locale = null, $download_type = null ) { // Map Order # terms to the actual charge id to be used in the server. if ( ! empty( $filters['search'] ) ) { $filters['search'] = WC_Payments_Utils::map_search_orders_to_charge_ids( $filters['search'] ); @@ -451,6 +452,9 @@ public function get_transactions_export( $filters = [], $user_email = '', $depos if ( ! empty( $locale ) ) { $filters['locale'] = $locale; } + if ( ! empty( $download_type ) ) { + $filters['download_type'] = $download_type; + } return $this->request( $filters, self::TRANSACTIONS_API . '/download', self::POST ); }