* Add export ID to success response and fix typos in endpoint schema.

* Initial export data store.

Allows for starting export, but not checking progress.

* Use new export data store in analytics report tables.

* Remove defunct report export wc-api files.

* Apply review feedback from another wp.data PR.

* Use getResourceName() util for more DRYness.

* Fix linter error.

* Add response status code to fetchWithHeaders control.

* Use HTTP status rather than response body to determine success.

* Fix tests.

* Remove unused import from actions.
This commit is contained in:
Jeff Stieler 2020-08-18 09:04:58 -04:00 committed by GitHub
parent df89bb7aac
commit c16da34c30
17 changed files with 390 additions and 156 deletions

View File

@ -8,7 +8,7 @@ import { compose } from '@wordpress/compose';
import { focus } from '@wordpress/dom';
import { withDispatch } from '@wordpress/data';
import { get, noop, partial, uniq } from 'lodash';
import { __ } from '@wordpress/i18n';
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { CompareButton, Search, TableCard } from '@woocommerce/components';
@ -26,6 +26,7 @@ import {
import {
getReportChartData,
getReportTableData,
EXPORT_STORE_NAME,
SETTINGS_STORE_NAME,
useUserPreferences,
} from '@woocommerce/data';
@ -152,7 +153,7 @@ const ReportTable = ( props ) => {
};
const onClickDownload = () => {
const { initiateReportExport, title } = props;
const { createNotice, startExport, title } = props;
const params = Object.assign( {}, query );
const { data, totalResults } = items;
let downloadType = 'browser';
@ -173,7 +174,34 @@ const ReportTable = ( props ) => {
);
} else {
downloadType = 'email';
initiateReportExport( endpoint, title, reportQuery );
startExport( endpoint, reportQuery )
.then( () =>
createNotice(
'success',
sprintf(
/* translators: %s = type of report */
__(
'Your %s Report will be emailed to you.',
'woocommerce-admin'
),
title
)
)
)
.catch( ( error ) =>
createNotice(
'error',
error.message ||
sprintf(
/* translators: %s = type of report */
__(
'There was a problem exporting your %s Report. Please try again.',
'woocommerce-admin'
),
title
)
)
);
}
recordEvent( 'analytics_table_download', {
@ -600,10 +628,12 @@ export default compose(
};
} ),
withDispatch( ( dispatch ) => {
const { initiateReportExport } = dispatch( 'wc-api' );
const { startExport } = dispatch( EXPORT_STORE_NAME );
const { createNotice } = dispatch( 'core/notices' );
return {
initiateReportExport,
createNotice,
startExport,
};
} )
)( ReportTable );

View File

@ -1,10 +0,0 @@
/**
* Internal dependencies
*/
import mutations from './mutations';
import operations from './operations';
export default {
mutations,
operations,
};

View File

@ -1,58 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { dispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { getResourceName } from '../utils';
const initiateReportExport = ( operations ) => async (
reportType,
reportTitle,
reportArgs
) => {
const { createNotice } = dispatch( 'core/notices' );
const resourceName = getResourceName(
`report-export-${ reportType }`,
reportArgs
);
const result = await operations.update( [ resourceName ], {
[ resourceName ]: reportArgs,
} );
const response = result[ 0 ][ resourceName ];
if ( response && response.success ) {
createNotice(
'success',
sprintf(
__(
'Your %s Report will be emailed to you.',
'woocommerce-admin'
),
reportTitle
)
);
}
if ( response && response.error ) {
createNotice(
'error',
sprintf(
__(
'There was a problem exporting your %s Report. Please try again.',
'woocommerce-admin'
),
reportTitle
)
);
}
};
export default {
initiateReportExport,
};

View File

@ -1,47 +0,0 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { getResourcePrefix } from '../utils';
import { NAMESPACE } from '../constants';
function update( resourceNames, data, fetch = apiFetch ) {
return [ ...initiateExport( resourceNames, data, fetch ) ];
}
function initiateExport( resourceNames, data, fetch ) {
const filteredNames = resourceNames.filter( ( name ) => {
return name.startsWith( 'report-export-' );
} );
return filteredNames.map( async ( resourceName ) => {
const prefix = getResourcePrefix( resourceName );
const reportType = prefix.split( '-' ).pop();
const url = NAMESPACE + '/reports/' + reportType + '/export';
try {
const result = await fetch( {
path: url,
method: 'POST',
data: {
report_args: data[ resourceName ],
email: true,
},
} );
return {
[ resourceName ]: { [ result.status ]: result.message },
};
} catch ( error ) {
return { [ resourceName ]: { error } };
}
} );
}
export default {
update,
};

View File

@ -1,7 +1,6 @@
/**
* Internal dependencies
*/
import reportExport from './export';
import items from './items';
import imports from './imports';
@ -9,7 +8,6 @@ function createWcApiSpec() {
return {
name: 'wcApi',
mutations: {
...reportExport.mutations,
...items.mutations,
},
selectors: {
@ -29,10 +27,7 @@ function createWcApiSpec() {
];
},
update( resourceNames, data ) {
return [
...reportExport.operations.update( resourceNames, data ),
...items.operations.update( resourceNames, data ),
];
return [ ...items.operations.update( resourceNames, data ) ];
},
updateLocally( resourceNames, data ) {
return [

View File

@ -17,9 +17,17 @@ const controls = {
FETCH_WITH_HEADERS( { options } ) {
return apiFetch( { ...options, parse: false } )
.then( ( response ) => {
return Promise.all( [ response.headers, response.json() ] );
return Promise.all( [
response.headers,
response.status,
response.json(),
] );
} )
.then( ( [ headers, data ] ) => ( { headers, data } ) );
.then( ( [ headers, status, data ] ) => ( {
headers,
status,
data,
} ) );
},
};

View File

@ -0,0 +1,8 @@
const TYPES = {
START_EXPORT: 'START_EXPORT',
SET_EXPORT_ID: 'SET_EXPORT_ID',
SET_ERROR: 'SET_ERROR',
SET_IS_REQUESTING: 'SET_IS_REQUESTING',
};
export default TYPES;

View File

@ -0,0 +1,64 @@
/**
* Internal dependencies
*/
import { fetchWithHeaders } from '../controls';
import TYPES from './action-types';
import { NAMESPACE } from '../constants';
export function setExportId( exportType, exportArgs, exportId ) {
return {
type: TYPES.SET_EXPORT_ID,
exportType,
exportArgs,
exportId,
};
}
export function setIsRequesting( selector, selectorArgs, isRequesting ) {
return {
type: TYPES.SET_IS_REQUESTING,
selector,
selectorArgs,
isRequesting,
};
}
export function setError( selector, selectorArgs, error ) {
return {
type: TYPES.SET_ERROR,
selector,
selectorArgs,
error,
};
}
export function* startExport( type, args ) {
yield setIsRequesting( 'startExport', { type, args }, true );
try {
const response = yield fetchWithHeaders( {
path: `${ NAMESPACE }/reports/${ type }/export`,
method: 'POST',
data: {
report_args: args,
email: true,
},
} );
yield setIsRequesting( 'startExport', { type, args }, false );
const { export_id: exportId, message } = response.data;
if ( exportId ) {
yield setExportId( type, args, exportId );
} else {
throw new Error( message );
}
return response.data;
} catch ( error ) {
yield setError( 'startExport', { type, args }, error.message );
yield setIsRequesting( 'startExport', { type, args }, false );
throw error;
}
}

View File

@ -0,0 +1,4 @@
/**
* Internal dependencies
*/
export const STORE_NAME = 'wc/admin/export';

View File

@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { select, registerStore } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_NAME } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import controls from '../controls';
import reducer from './reducer';
const storeSelectors = select( STORE_NAME );
// @todo This is used to prevent double registration of the store due to webpack chunks.
// The `storeSelectors` condition can be removed once this is fixed.
if ( ! storeSelectors ) {
registerStore( STORE_NAME, {
reducer,
actions,
controls,
selectors,
} );
}
export const EXPORT_STORE_NAME = STORE_NAME;

View File

@ -0,0 +1,74 @@
/**
* Internal dependencies
*/
import TYPES from './action-types';
import { hashExportArgs } from './utils';
const exportReducer = (
state = {
errors: {},
requesting: {},
exportMeta: {},
exportIds: {},
},
{
error,
exportArgs,
exportId,
exportType,
isRequesting,
selector,
selectorArgs,
type,
}
) => {
switch ( type ) {
case TYPES.SET_IS_REQUESTING:
return {
...state,
requesting: {
...state.requesting,
[ selector ]: {
...state.requesting[ selector ],
[ hashExportArgs( selectorArgs ) ]: isRequesting,
},
},
};
case TYPES.SET_EXPORT_ID:
return {
...state,
exportMeta: {
...state.exportMeta,
[ exportId ]: {
exportType,
exportArgs,
},
},
exportIds: {
...state.exportIds,
[ exportType ]: {
...state.exportIds[ exportType ],
[ hashExportArgs( {
type: exportType,
args: exportArgs,
} ) ]: exportId,
},
},
};
case TYPES.SET_ERROR:
return {
...state,
errors: {
...state.errors,
[ selector ]: {
...state.errors[ selector ],
[ hashExportArgs( selectorArgs ) ]: error,
},
},
};
default:
return state;
}
};
export default exportReducer;

View File

@ -0,0 +1,25 @@
/**
* Internal dependencies
*/
import { hashExportArgs } from './utils';
export const isExportRequesting = ( state, selector, selectorArgs ) => {
return Boolean(
state.requesting[ selector ] &&
state.requesting[ selector ][ hashExportArgs( selectorArgs ) ]
);
};
export const getExportId = ( state, exportType, exportArgs ) => {
return (
state.exportIds[ exportType ] &&
state.exportIds[ exportType ][ hashExportArgs( exportArgs ) ]
);
};
export const getError = ( state, selector, selectorArgs ) => {
return (
state.errors[ selector ] &&
state.errors[ selector ][ hashExportArgs( selectorArgs ) ]
);
};

View File

@ -0,0 +1,92 @@
/**
* Internal dependencies
*/
import reducer from '../reducer';
import TYPES from '../action-types';
import { hashExportArgs } from '../utils';
const defaultState = {
errors: {},
requesting: {},
exportMeta: {},
exportIds: {},
};
describe( 'export reducer', () => {
it( 'should return a default state', () => {
const state = reducer( undefined, {} );
expect( state ).toEqual( defaultState );
expect( state ).not.toBe( defaultState );
} );
it( 'should handle SET_IS_REQUESTING', () => {
const selectorArgs = {
type: 'orders',
args: {
after: '2020-01-01T00:00:00',
before: '2019-12-31T23:59:59',
status_is: 'pending',
},
};
const state = reducer( defaultState, {
type: TYPES.SET_IS_REQUESTING,
selector: 'startExport',
selectorArgs,
isRequesting: true,
} );
expect(
state.requesting.startExport[ hashExportArgs( selectorArgs ) ]
).toBe( true );
} );
it( 'should handle SET_EXPORT_ID', () => {
const exportType = 'orders';
const exportArgs = {
after: '2020-01-01T00:00:00',
before: '2019-12-31T23:59:59',
status_is: 'pending',
};
const hashArgs = {
type: exportType,
args: exportArgs,
};
const exportId = '15967352870671';
const state = reducer( defaultState, {
type: TYPES.SET_EXPORT_ID,
exportType,
exportArgs,
exportId,
} );
expect(
state.exportIds[ exportType ][ hashExportArgs( hashArgs ) ]
).toBe( exportId );
expect( state.exportMeta[ exportId ] ).toEqual( {
exportType,
exportArgs,
} );
} );
it( 'should handle SET_ERROR', () => {
const selectorArgs = {
type: 'orders',
args: {
after: '2020-01-01T00:00:00',
before: '2019-12-31T23:59:59',
status_is: 'pending',
},
};
const error = 'There is no data to export for the given request.';
const state = reducer( defaultState, {
type: TYPES.SET_ERROR,
selector: 'startExport',
selectorArgs,
error,
} );
expect(
state.errors.startExport[ hashExportArgs( selectorArgs ) ]
).toBe( error );
} );
} );

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import crypto from 'crypto';
/**
* Internal dependencies
*/
import { getResourceName } from '../utils';
export const hashExportArgs = ( args ) => {
return crypto
.createHash( 'md5' )
.update( getResourceName( 'export', args ) )
.digest( 'hex' );
};

View File

@ -32,4 +32,6 @@ export {
export { MAX_PER_PAGE, QUERY_DEFAULTS } from './constants';
export { EXPORT_STORE_NAME } from './export';
export { __experimentalResolveSelect } from './registry';

View File

@ -96,14 +96,20 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
'title' => 'report_export',
'type' => 'object',
'properties' => array(
'status' => array(
'description' => __( 'Regeneration status.', 'woocommerce-admin' ),
'status' => array(
'description' => __( 'Export status.', 'woocommerce-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'message' => array(
'description' => __( 'Regenerate data message.', 'woocommerce-admin' ),
'message' => array(
'description' => __( 'Export status message.', 'woocommerce-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'export_id' => array(
'description' => __( 'Export ID.', 'woocommerce-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
@ -154,37 +160,34 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
$report_args = empty( $request['report_args'] ) ? array() : $request['report_args'];
$send_email = isset( $request['email'] ) ? $request['email'] : false;
$export_id = str_replace( '.', '', microtime( true ) );
$total_rows = ReportExporter::queue_report_export( $export_id, $report_type, $report_args, $send_email );
$total_rows = ReportExporter::queue_report_export( $export_id, $report_type, $report_args, $send_email );
if ( 0 === $total_rows ) {
$response = rest_ensure_response(
return rest_ensure_response(
array(
'status' => 'error',
'message' => __( 'There is no data to export for the given request.', 'woocommerce-admin' ),
)
);
} else {
$response = rest_ensure_response(
array(
'status' => 'success',
'message' => __( 'Your report file is being generated.', 'woocommerce-admin' ),
)
);
// Include a link to the export status endpoint.
$response->add_links(
array(
'status' => array(
'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ),
),
)
);
ReportExporter::update_export_percentage_complete( $report_type, $export_id, 0 );
}
ReportExporter::update_export_percentage_complete( $report_type, $export_id, 0 );
$response = rest_ensure_response(
array(
'message' => __( 'Your report file is being generated.', 'woocommerce-admin' ),
'export_id' => $export_id,
)
);
// Include a link to the export status endpoint.
$response->add_links(
array(
'status' => array(
'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ),
),
)
);
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );

View File

@ -126,7 +126,6 @@ class WC_Tests_API_Reports_Export extends WC_REST_Unit_Test_Case {
$status_url = $export['_links']['status'][0]['href'];
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'success', $export['status'] );
$this->assertEquals( 'Your report file is being generated.', $export['message'] );
$this->assertStringMatchesFormat( '%s/wc-analytics/reports/taxes/export/%d/status', $status_url );