* Add Products table

* Update tests

* Cleanup

* Make placeholder table have the correct sorted column selected

* Change default sort to items_sold & desc

* Fix wrong CSV filename

* Remove unnecessary constructor in ProductsReportTable

* Add @TODO comment to Products table summary

* Set ProductsReportTable link to wc-admin type

* Make sure categories is an array before using map
This commit is contained in:
Albert Juhé Lluveras 2018-10-24 09:50:05 +02:00 committed by GitHub
parent 7be34b3f37
commit 29f68a9ce8
13 changed files with 346 additions and 270 deletions

View File

@ -198,7 +198,7 @@ export default class OrdersReportTable extends Component {
renderLinks( items = [] ) {
return items.map( ( item, i ) => (
<Link href={ item.href } key={ i } type={ 'wp-admin' }>
<Link href={ item.href } key={ i } type="wp-admin">
{ item.label }
</Link>
) );

View File

@ -1,54 +0,0 @@
/** @format */
export default [
{
product_id: 658,
items_sold: 8,
gross_revenue: 6625.76,
orders_count: 1,
name: 'Mug',
stock_quantity: 184,
categories: [],
variations: [],
},
{
product_id: 412,
items_sold: 4,
gross_revenue: 60,
orders_count: 1,
name: 'Beanie',
stock_quantity: 98,
categories: [],
variations: [],
},
{
product_id: 724,
items_sold: 11,
gross_revenue: 6552.37,
orders_count: 2,
name: 'Sweater',
stock_quantity: null,
categories: [],
variations: [],
},
{
product_id: 653,
items_sold: 14,
gross_revenue: 2566.76,
orders_count: 2,
name: 'Octopus Tee',
stock_quantity: 114,
categories: [],
variations: [],
},
{
product_id: 254,
items_sold: 9,
gross_revenue: 135,
orders_count: 1,
name: 'Bubble Tee',
stock_quantity: 79,
categories: [],
variations: [ 255, 256, 257 ],
},
];

View File

@ -2,170 +2,77 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { map, noop } from 'lodash';
import { compose } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { get } from 'lodash';
/**
* Internal dependencies
*/
import { filters } from './config';
import { formatCurrency, getCurrencyFormatDecimal } from 'lib/currency';
import { numberFormat } from 'lib/number';
import { getAdminLink, onQueryChange } from 'lib/nav-utils';
import { ReportFilters, TableCard } from '@woocommerce/components';
import { ReportFilters } from '@woocommerce/components';
import { appendTimestamp, getCurrentDates } from 'lib/date';
import { getReportChartData } from 'store/reports/utils';
import ProductsReportChart from './chart';
import products from './__mocks__/data';
import ProductsReportTable from './table';
class ProductsReport extends Component {
getHeadersContent() {
return [
{
label: __( 'Product Title', 'wc-admin' ),
key: 'name',
required: true,
isLeftAligned: true,
isSortable: true,
},
{
label: __( 'Items Sold', 'wc-admin' ),
key: 'items_sold',
required: true,
defaultSort: true,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Gross Revenue', 'wc-admin' ),
key: 'gross_revenue',
required: true,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Orders', 'wc-admin' ),
key: 'orders_count',
required: false,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Category', 'wc-admin' ),
key: 'product_cat',
},
{
label: __( 'Variations', 'wc-admin' ),
key: 'variation',
},
{
label: __( 'Status', 'wc-admin' ),
key: 'stock_status',
},
{
label: __( 'Stock', 'wc-admin' ),
key: 'stock',
},
];
}
getRowsContent( data = [] ) {
return map( data, row => {
const {
product_id,
items_sold,
gross_revenue,
orders_count,
name,
stock_quantity,
variations,
} = row;
return [
{
display: (
<a href={ getAdminLink( 'post.php?action=edit&post=' + product_id ) }>{ name }</a>
),
value: name,
},
{
display: items_sold,
value: Number( items_sold ),
},
{
display: formatCurrency( gross_revenue ),
value: getCurrencyFormatDecimal( gross_revenue ),
},
{
display: orders_count,
value: Number( orders_count ),
},
{
display: 'Categories',
value: false,
},
{
display: numberFormat( variations.length ),
value: false,
},
{
display: 'Status',
value: false,
},
{
display: numberFormat( stock_quantity ),
value: Number( stock_quantity ),
},
];
} );
}
renderTable() {
const { query } = this.props;
const rowsPerPage = parseInt( query.per_page ) || 25;
const rows = this.getRowsContent( products );
const headers = this.getHeadersContent();
const labels = {
helpText: __( 'Select at least two products to compare', 'wc-admin' ),
placeholder: __( 'Search by product name or SKU', 'wc-admin' ),
};
const tableQuery = {
...query,
orderby: query.orderby || 'date_start',
order: query.order || 'asc',
};
return (
<TableCard
title={ __( 'Products', 'wc-admin' ) }
rows={ rows }
totalRows={ 500 }
rowsPerPage={ rowsPerPage }
headers={ headers }
compareBy={ 'product' }
labels={ labels }
ids={ products.map( p => p.product_id ) }
onClickDownload={ noop }
onQueryChange={ onQueryChange }
query={ tableQuery }
summary={ null }
/>
);
}
render() {
const { query, path } = this.props;
const {
isProductsError,
isProductsRequesting,
path,
primaryData,
products,
query,
} = this.props;
return (
<Fragment>
<ReportFilters query={ query } path={ path } filters={ filters } />
<ProductsReportChart query={ query } />
{ this.renderTable() }
<ProductsReportTable
isError={ isProductsError || primaryData.isError }
isRequesting={ isProductsRequesting || primaryData.isRequesting }
products={ products }
query={ query }
totalRows={ get(
primaryData,
[ 'data', 'totals', 'products_count' ],
Object.keys( products ).length
) }
/>
</Fragment>
);
}
}
export default ProductsReport;
export default compose(
withSelect( ( select, props ) => {
const { query } = props;
const datesFromQuery = getCurrentDates( query );
const primaryData = getReportChartData( 'products', 'primary', query, select );
const { getProducts, isGetProductsError, isGetProductsRequesting } = select( 'wc-admin' );
const tableQuery = {
orderby: query.orderby || 'items_sold',
order: query.order || 'desc',
page: query.page || 1,
per_page: query.per_page || 25,
after: appendTimestamp( datesFromQuery.primary.after, 'start' ),
before: appendTimestamp( datesFromQuery.primary.before, 'end' ),
extended_product_info: true,
};
const products = getProducts( tableQuery );
const isProductsError = isGetProductsError( tableQuery );
const isProductsRequesting = isGetProductsRequesting( tableQuery );
return {
isProductsError,
isProductsRequesting,
primaryData,
products,
};
} )
)( ProductsReport );

View File

@ -0,0 +1,219 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { map, orderBy } from 'lodash';
/**
* Internal dependencies
*/
import { Card, Link, TableCard, TablePlaceholder } from '@woocommerce/components';
import { downloadCSVFile, generateCSVDataFromTable, generateCSVFileName } from 'lib/csv';
import { formatCurrency, getCurrencyFormatDecimal } from 'lib/currency';
import { onQueryChange } from 'lib/nav-utils';
import ReportError from 'analytics/components/report-error';
export default class ProductsReportTable extends Component {
onDownload( headers, rows, query ) {
// @TODO The current implementation only downloads the contents displayed in the table.
// Another solution is required when the data set is larger (see #311).
return () => {
downloadCSVFile(
generateCSVFileName( 'products', query ),
generateCSVDataFromTable( headers, rows )
);
};
}
getHeadersContent() {
return [
{
label: __( 'Product Title', 'wc-admin' ),
key: 'name',
required: true,
isLeftAligned: true,
},
{
label: __( 'SKU', 'wc-admin' ),
key: 'sku',
hiddenByDefault: true,
},
{
label: __( 'Items Sold', 'wc-admin' ),
key: 'items_sold',
required: true,
defaultSort: true,
isSortable: true,
isNumeric: true,
},
{
label: __( 'G. Revenue', 'wc-admin' ),
key: 'gross_revenue',
required: true,
isSortable: true,
isNumeric: true,
},
{
label: __( 'Orders', 'wc-admin' ),
key: 'orders_count',
isSortable: true,
isNumeric: true,
},
{
label: __( 'Category', 'wc-admin' ),
key: 'product_cat',
},
{
label: __( 'Variations', 'wc-admin' ),
key: 'variation',
},
{
label: __( 'Status', 'wc-admin' ),
key: 'stock_status',
},
{
label: __( 'Stock', 'wc-admin' ),
key: 'stock',
},
];
}
getRowsContent( data = [] ) {
const { stockStatuses } = wcSettings;
return map( data, row => {
const {
product_id,
sku = '', // @TODO
name,
items_sold,
gross_revenue,
orders_count,
categories = [], // @TODO
variations = [], // @TODO
stock_status = 'outofstock', // @TODO
stock_quantity = '0', // @TODO
} = row;
return [
{
// @TODO Must link to the product detail report.
display: name,
value: name,
},
{
display: sku,
value: sku,
},
{
display: items_sold,
value: items_sold,
},
{
display: formatCurrency( gross_revenue ),
value: getCurrencyFormatDecimal( gross_revenue ),
},
{
display: (
<Link href={ 'orders?filter=advanced&product_includes=' + product_id } type="wc-admin">
{ orders_count }
</Link>
),
value: orders_count,
},
{
display: Array.isArray( categories )
? categories.map( cat => cat.name ).join( ', ' )
: '',
value: Array.isArray( categories ) ? categories.map( cat => cat.name ).join( ', ' ) : '',
},
{
display: variations.length,
value: variations.length,
},
{
display: (
<Link href={ 'post.php?action=edit&post=' + product_id } type="wp-admin">
{ stockStatuses[ stock_status ] }
</Link>
),
value: stockStatuses[ stock_status ],
},
{
display: stock_quantity,
value: stock_quantity,
},
];
} );
}
renderPlaceholderTable( tableQuery ) {
const headers = this.getHeadersContent();
return (
<Card
title={ __( 'Products', 'wc-admin' ) }
className="woocommerce-analytics__table-placeholder"
>
<TablePlaceholder
caption={ __( 'Products', 'wc-admin' ) }
headers={ headers }
query={ tableQuery }
/>
</Card>
);
}
renderTable( tableQuery ) {
const { products, query, totalRows } = this.props;
const rowsPerPage = parseInt( tableQuery.per_page ) || 25;
const orderedProducts = orderBy( products, tableQuery.orderby, tableQuery.order );
const rows = this.getRowsContent( orderedProducts );
const headers = this.getHeadersContent();
const labels = {
helpText: __( 'Select at least two products to compare', 'wc-admin' ),
placeholder: __( 'Search by product name or SKU', 'wc-admin' ),
};
return (
<TableCard
title={ __( 'Products', 'wc-admin' ) }
rows={ rows }
totalRows={ totalRows }
rowsPerPage={ rowsPerPage }
headers={ headers }
labels={ labels }
ids={ orderedProducts.map( p => p.product_id ) }
compareBy={ 'product' }
onClickDownload={ this.onDownload( headers, rows, query ) }
onQueryChange={ onQueryChange }
query={ tableQuery }
summary={ null } // @TODO
/>
);
}
render() {
const { isError, isRequesting, query } = this.props;
if ( isError ) {
return <ReportError isError />;
}
const tableQuery = {
...query,
orderby: query.orderby || 'items_sold',
order: query.order || 'desc',
};
if ( isRequesting ) {
return this.renderPlaceholderTable( tableQuery );
}
return this.renderTable( tableQuery );
}
}

View File

@ -5,7 +5,7 @@
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { Component } from '@wordpress/element';
import { fill, find, findIndex, first, isEqual, noop, partial, uniq } from 'lodash';
import { find, findIndex, first, isEqual, noop, partial, uniq } from 'lodash';
import { IconButton, ToggleControl } from '@wordpress/components';
import PropTypes from 'prop-types';
@ -39,7 +39,7 @@ class TableCard extends Component {
super( props );
const { compareBy, query } = props;
this.state = {
showCols: fill( Array( props.headers.length ), true ),
showCols: props.headers.map( ( { hiddenByDefault } ) => ! hiddenByDefault ),
selectedRows: getIdsFromQuery( query[ compareBy ] ),
};
this.toggleCols = this.toggleCols.bind( this );
@ -302,6 +302,7 @@ TableCard.propTypes = {
*/
headers: PropTypes.arrayOf(
PropTypes.shape( {
hiddenByDefault: PropTypes.bool,
defaultSort: PropTypes.bool,
isSortable: PropTypes.bool,
key: PropTypes.string,

View File

@ -17,8 +17,9 @@ import Table from './table';
class TablePlaceholder extends Component {
render() {
const { caption, headers, numberOfRows, query } = this.props;
const filteredHeaders = headers.filter( header => ! header.hiddenByDefault );
const rows = range( numberOfRows ).map( () =>
headers.map( () => ( { display: <span className="is-placeholder" /> } ) )
filteredHeaders.map( () => ( { display: <span className="is-placeholder" /> } ) )
);
return (
@ -26,7 +27,7 @@ class TablePlaceholder extends Component {
ariaHidden={ true }
caption={ caption }
classNames="is-loading"
headers={ headers }
headers={ filteredHeaders }
rowHeader={ false }
rows={ rows }
query={ query }
@ -49,6 +50,7 @@ TablePlaceholder.propTypes = {
*/
headers: PropTypes.arrayOf(
PropTypes.shape( {
hiddenByDefault: PropTypes.bool,
defaultSort: PropTypes.bool,
isSortable: PropTypes.bool,
key: PropTypes.string,

View File

@ -2,7 +2,7 @@
/**
* External dependencies
*/
import { get } from 'lodash';
import { merge } from 'lodash';
/**
* Internal dependencies
@ -10,34 +10,22 @@ import { get } from 'lodash';
import { ERROR } from 'store/constants';
import { getJsonString } from 'store/utils';
export const DEFAULT_STATE = {
queries: {},
};
export const DEFAULT_STATE = {};
export default function productsReducer( state = DEFAULT_STATE, action ) {
if ( 'SET_PRODUCTS' === action.type ) {
const prevQueries = get( state, 'queries', {} );
const queryKey = getJsonString( action.query );
const queries = {
...prevQueries,
[ queryKey ]: [ ...action.products ],
};
return {
...state,
queries,
};
}
if ( 'SET_PRODUCTS_ERROR' === action.type ) {
const prevQueries = get( state, 'queries', {} );
const queryKey = getJsonString( action.query );
const queries = {
...prevQueries,
[ queryKey ]: ERROR,
};
return {
...state,
queries,
};
const queryKey = getJsonString( action.query );
switch ( action.type ) {
case 'SET_PRODUCTS':
return merge( {}, state, {
[ queryKey ]: action.products,
} );
case 'SET_PRODUCTS_ERROR':
return merge( {}, state, {
[ queryKey ]: ERROR,
} );
}
return state;
}

View File

@ -10,7 +10,7 @@ export default {
async getProducts( state, query ) {
try {
const params = query ? '?' + stringify( query ) : '';
const products = await apiFetch( { path: '/wc/v3/products' + params } );
const products = await apiFetch( { path: '/wc/v3/reports/products' + params } );
dispatch( 'wc-admin' ).setProducts( products, query );
} catch ( error ) {
dispatch( 'wc-admin' ).setProductsError( query );

View File

@ -20,8 +20,7 @@ import { getJsonString } from 'store/utils';
* @return {Object} Report details
*/
function getProducts( state, query = {} ) {
const queries = get( state, 'products.queries', {} );
return queries[ getJsonString( query ) ];
return get( state, [ 'products', getJsonString( query ) ], [] );
}
export default {
@ -33,7 +32,7 @@ export default {
* @param {Object} state Current state
* @return {Object} True if the `getProducts` request is pending, false otherwise
*/
isProductsRequesting( state, ...args ) {
isGetProductsRequesting( state, ...args ) {
return select( 'core/data' ).isResolving( 'wc-admin', 'getProducts', args );
},
@ -44,7 +43,7 @@ export default {
* @param {Object} query Report query paremters
* @return {Object} True if the `getProducts` request has failed, false otherwise
*/
isProductsError( state, query ) {
isGetProductsError( state, query ) {
return ERROR === getProducts( state, query );
},
};

View File

@ -38,7 +38,7 @@ describe( 'productsReducer', () => {
products,
} );
const queryKey = getJsonString( query );
expect( state.queries[ queryKey ] ).toEqual( products );
expect( state[ queryKey ] ).toEqual( products );
} );
it( 'returns received product data for multiple queries', () => {
@ -76,8 +76,8 @@ describe( 'productsReducer', () => {
const queryKey1 = getJsonString( query1 );
const queryKey2 = getJsonString( query2 );
expect( finalState.queries[ queryKey1 ] ).toEqual( products1 );
expect( finalState.queries[ queryKey2 ] ).toEqual( products2 );
expect( finalState[ queryKey1 ] ).toEqual( products1 );
expect( finalState[ queryKey2 ] ).toEqual( products2 );
} );
it( 'returns error appropriately', () => {
@ -91,6 +91,6 @@ describe( 'productsReducer', () => {
query,
} );
const queryKey = getJsonString( query );
expect( state.queries[ queryKey ] ).toEqual( ERROR );
expect( state[ queryKey ] ).toEqual( ERROR );
} );
} );

View File

@ -17,22 +17,43 @@ const { getProducts } = resolvers;
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
describe( 'getProducts', () => {
const products = [
const PRODUCTS_1 = [
{
id: 3,
name: 'my-product-3',
},
{
id: 4,
name: 'my-product-2-4',
},
];
const PRODUCTS_2 = [
{
id: 1,
name: 'my-product',
name: 'my-product-1',
},
{
id: 2,
name: 'my-product-2',
},
];
beforeAll( () => {
apiFetch.mockImplementation( options => {
if ( options.path === '/wc/v3/products?search=abc' ) {
return Promise.resolve( products );
if ( options.path === '/wc/v3/products' ) {
return Promise.resolve( PRODUCTS_1 );
}
if ( options.path === '/wc/v3/products?orderby=date' ) {
return Promise.resolve( PRODUCTS_2 );
}
} );
} );
it( 'returns requested products', async () => {
getProducts( {}, { search: 'abc' } ).then( data => expect( data ).toEqual( products ) );
getProducts().then( data => expect( data ).toEqual( PRODUCTS_1 ) );
} );
it( 'returns requested products for a specific query', async () => {
getProducts( { orderby: 'date' } ).then( data => expect( data ).toEqual( PRODUCTS_2 ) );
} );
} );

View File

@ -15,16 +15,19 @@ import selectors from '../selectors';
import { select } from '@wordpress/data';
import { getJsonString } from 'store/utils';
const { getProducts, isProductsRequesting, isProductsError } = selectors;
const { getProducts, isGetProductsRequesting, isGetProductsError } = selectors;
jest.mock( '@wordpress/data', () => ( {
...require.requireActual( '@wordpress/data' ),
select: jest.fn().mockReturnValue( {} ),
} ) );
const query = { orderby: 'date' };
const queryKey = getJsonString( query );
describe( 'getProducts', () => {
it( 'returns undefined when no query matches values in state', () => {
it( 'returns an empty array when no query matches values in state', () => {
const state = deepFreeze( {} );
expect( getProducts( state, { search: 'abc' } ) ).toEqual( undefined );
expect( getProducts( state, query ) ).toEqual( [] );
} );
it( 'returns products for a given query', () => {
@ -34,20 +37,16 @@ describe( 'getProducts', () => {
name: 'my-product',
},
];
const query = { search: 'abc' };
const queryKey = getJsonString( query );
const state = deepFreeze( {
products: {
queries: {
[ queryKey ]: products,
},
[ queryKey ]: products,
},
} );
expect( getProducts( state, query ) ).toEqual( products );
} );
} );
describe( 'isProductsRequesting', () => {
describe( 'isGetProductsRequesting', () => {
beforeAll( () => {
select( 'core/data' ).isResolving = jest.fn().mockReturnValue( false );
} );
@ -56,8 +55,6 @@ describe( 'isProductsRequesting', () => {
select( 'core/data' ).isResolving.mockRestore();
} );
const query = { search: 'abc' };
function setIsResolving( isResolving ) {
select( 'core/data' ).isResolving.mockImplementation(
( reducerKey, selectorName ) =>
@ -66,39 +63,34 @@ describe( 'isProductsRequesting', () => {
}
it( 'returns false if never requested', () => {
const result = isProductsRequesting( query );
const result = isGetProductsRequesting( query );
expect( result ).toBe( false );
} );
it( 'returns false if request finished', () => {
setIsResolving( false );
const result = isProductsRequesting( query );
const result = isGetProductsRequesting( query );
expect( result ).toBe( false );
} );
it( 'returns true if requesting', () => {
setIsResolving( true );
const result = isProductsRequesting( query );
const result = isGetProductsRequesting( query );
expect( result ).toBe( true );
} );
} );
describe( 'isProductsError', () => {
const query = { search: 'abc' };
describe( 'isGetProductsError', () => {
it( 'returns false by default', () => {
const state = deepFreeze( {} );
expect( isProductsError( state, query ) ).toEqual( false );
expect( isGetProductsError( state, query ) ).toEqual( false );
} );
it( 'returns true if ERROR constant is found', () => {
const queryKey = getJsonString( query );
const state = deepFreeze( {
products: {
queries: {
[ queryKey ]: ERROR,
},
[ queryKey ]: ERROR,
},
} );
expect( isProductsError( state, query ) ).toEqual( true );
expect( isGetProductsError( state, query ) ).toEqual( true );
} );
} );

View File

@ -85,6 +85,7 @@ function wc_admin_register_script() {
'dow' => get_option( 'start_of_week', 0 ),
),
'orderStatuses' => wc_get_order_statuses(),
'stockStatuses' => wc_get_product_stock_status_options(),
'siteTitle' => get_bloginfo( 'name' ),
'trackingEnabled' => $tracking_enabled,
);