Merge pull request woocommerce/woocommerce-admin#764 from woocommerce/add/variations-table

Product Detail: Add Variations Table
This commit is contained in:
Paul Sealock 2018-11-20 13:48:37 +13:00 committed by GitHub
commit 1a9ce46cdb
12 changed files with 602 additions and 6 deletions

View File

@ -19,15 +19,16 @@ import getSelectedChart from 'lib/get-selected-chart';
import ProductsReportTable from './table';
import ReportChart from 'analytics/components/report-chart';
import ReportSummary from 'analytics/components/report-summary';
import VariationsReportTable from './table-variations';
export default class ProductsReport extends Component {
render() {
const { path, query } = this.props;
const isProductDetailsView = query.products && 1 === query.products.split( ',' ).length;
const itemsLabel =
'single_product' === query.filter && !! query.products
? __( '%s variations', 'wc-admin' )
: __( '%s products', 'wc-admin' );
const itemsLabel = isProductDetailsView
? __( '%s variations', 'wc-admin' )
: __( '%s products', 'wc-admin' );
return (
<Fragment>
@ -47,7 +48,11 @@ export default class ProductsReport extends Component {
query={ query }
selectedChart={ getSelectedChart( query.chart, charts ) }
/>
<ProductsReportTable query={ query } />
{ isProductDetailsView ? (
<VariationsReportTable query={ query } />
) : (
<ProductsReportTable query={ query } />
) }
</Fragment>
);
}

View File

@ -0,0 +1,199 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { map, orderBy } from 'lodash';
/**
* WooCommerce dependencies
*/
import { Link, TableCard } from '@woocommerce/components';
import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency';
import { appendTimestamp, getCurrentDates } from '@woocommerce/date';
import { getNewPath, getPersistedQuery, onQueryChange } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import ReportError from 'analytics/components/report-error';
import { getFilterQuery, getReportChartData } from 'store/reports/utils';
import { QUERY_DEFAULTS } from 'store/constants';
class VariationsReportTable extends Component {
getVariationName( variation ) {
return variation.attributes.reduce( ( desc, attribute, index, arr ) => {
desc += `${ attribute.option }${ arr.length === index + 1 ? '' : ', ' }`;
return desc;
}, variation.product_name + ' / ' );
}
getHeadersContent() {
return [
{
label: __( 'Product / Variation Title', 'wc-admin' ),
key: 'name',
required: true,
isLeftAligned: 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: __( 'Status', 'wc-admin' ),
key: 'stock_status',
},
{
label: __( 'Stock', 'wc-admin' ),
key: 'stock',
isNumeric: true,
},
];
}
getRowsContent( data = [] ) {
const { stockStatuses } = wcSettings;
const { query } = this.props;
const persistedQuery = getPersistedQuery( query );
return map( data, row => {
const {
items_sold,
gross_revenue,
orders_count,
stock_status = 'outofstock',
stock_quantity = '0',
product_id,
} = row;
const name = this.getVariationName( row );
const ordersLink = getNewPath( persistedQuery, 'orders', {
filter: 'advanced',
product_includes: query.products,
} );
return [
{
display: name,
value: name,
},
{
display: items_sold,
value: items_sold,
},
{
display: formatCurrency( gross_revenue ),
value: getCurrencyFormatDecimal( gross_revenue ),
},
{
display: (
<Link href={ ordersLink } type="wc-admin">
{ orders_count }
</Link>
),
value: orders_count,
},
{
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,
},
];
} );
}
render() {
const { isVariationsError, isVariationsRequesting, variations, tableQuery } = this.props;
if ( isVariationsError ) {
return <ReportError isError />;
}
const headers = this.getHeadersContent();
const orderedVariations = orderBy( variations, tableQuery.orderby, tableQuery.order );
const rows = this.getRowsContent( orderedVariations );
const rowsPerPage = parseInt( tableQuery.per_page ) || QUERY_DEFAULTS.pageSize;
const labels = {
helpText: __( 'Select at least two variations to compare', 'wc-admin' ),
placeholder: __( 'Search by variation name', 'wc-admin' ),
};
return (
<TableCard
title={ __( 'Variations', 'wc-admin' ) }
rows={ rows }
totalRows={ variations.length }
rowsPerPage={ rowsPerPage }
headers={ headers }
labels={ labels }
ids={ orderedVariations.map( p => p.product_id ) }
isLoading={ isVariationsRequesting }
compareBy={ 'variations' }
compareParam={ 'filter-variations' }
onQueryChange={ onQueryChange }
query={ tableQuery }
summary={ null } // @TODO
downloadable
/>
);
}
}
export default compose(
withSelect( ( select, props ) => {
const { query } = props;
const primaryData = getReportChartData( 'products', 'primary', query, select );
const { getVariations, isGetVariationsError, isGetVariationsRequesting } = select( 'wc-admin' );
const filterQuery = getFilterQuery( 'products', query );
const datesFromQuery = getCurrentDates( query );
const tableQuery = {
orderby: query.orderby || 'items_sold',
order: query.order || 'desc',
after: appendTimestamp( datesFromQuery.primary.after, 'start' ),
before: appendTimestamp( datesFromQuery.primary.before, 'end' ),
page: query.page || 1,
per_page: query.per_page || QUERY_DEFAULTS.pageSize,
...filterQuery,
};
const variations = getVariations( tableQuery );
const isVariationsError = isGetVariationsError( tableQuery );
const isVariationsRequesting = isGetVariationsRequesting( tableQuery );
return {
isVariationsError,
isVariationsRequesting,
primaryData,
variations,
tableQuery,
};
} )
)( VariationsReportTable );

View File

@ -73,6 +73,7 @@ class ProductsReportTable extends Component {
{
label: __( 'Stock', 'wc-admin' ),
key: 'stock',
isNumeric: true,
},
];
}
@ -99,7 +100,7 @@ class ProductsReportTable extends Component {
filter: 'advanced',
product_includes: product_id,
} );
const productDetailLink = getNewPath( timeRelatedQuery, 'products', {
const productDetailLink = getNewPath( persistedQuery, 'products', {
filter: 'single_product',
products: product_id,
} );

View File

@ -14,6 +14,7 @@ import products from 'store/products';
import reports from 'store/reports';
import notes from 'store/notes';
import taxes from 'store/taxes';
import variations from 'store/variations';
const store = registerStore( 'wc-admin', {
reducer: combineReducers( {
@ -23,6 +24,7 @@ const store = registerStore( 'wc-admin', {
reports: reports.reducer,
notes: notes.reducer,
taxes: taxes.reducer,
variations: variations.reducer,
} ),
actions: {
@ -32,6 +34,7 @@ const store = registerStore( 'wc-admin', {
...reports.actions,
...notes.actions,
...taxes.actions,
...variations.actions,
},
selectors: {
@ -41,6 +44,7 @@ const store = registerStore( 'wc-admin', {
...reports.selectors,
...notes.selectors,
...taxes.selectors,
...variations.selectors,
},
resolvers: {
@ -50,6 +54,7 @@ const store = registerStore( 'wc-admin', {
...reports.resolvers,
...notes.resolvers,
...taxes.resolvers,
...variations.resolvers,
},
} );

View File

@ -0,0 +1,18 @@
/** @format */
export default {
setVariations( variations, query ) {
return {
type: 'SET_VARIATIONS',
variations,
query: query || {},
};
},
setVariationsError( query ) {
return {
type: 'SET_VARIATIONS_ERROR',
query: query || {},
};
},
};

View File

@ -0,0 +1,15 @@
/** @format */
/**
* Internal dependencies
*/
import actions from './actions';
import reducer from './reducer';
import resolvers from './resolvers';
import selectors from './selectors';
export default {
actions,
reducer,
resolvers,
selectors,
};

View File

@ -0,0 +1,31 @@
/** @format */
/**
* External dependencies
*/
import { merge } from 'lodash';
/**
* Internal dependencies
*/
import { ERROR } from 'store/constants';
import { getJsonString } from 'store/utils';
export const DEFAULT_STATE = {};
export default function variationsReducer( state = DEFAULT_STATE, action ) {
const queryKey = getJsonString( action.query );
switch ( action.type ) {
case 'SET_VARIATIONS':
return merge( {}, state, {
[ queryKey ]: action.variations,
} );
case 'SET_VARIATIONS_ERROR':
return merge( {}, state, {
[ queryKey ]: ERROR,
} );
}
return state;
}

View File

@ -0,0 +1,29 @@
/** @format */
/**
* External dependencies
*/
// import apiFetch from '@wordpress/api-fetch';
import { dispatch } from '@wordpress/data';
import { stringify } from 'qs';
import { isEmpty } from 'lodash';
export default {
async getVariations( ...args ) {
const query = args.length === 1 ? args[ 0 ] : args[ 1 ];
try {
const params = isEmpty( query ) ? '' : '?' + stringify( query );
// @TODO: Use /reports/variations when it becomes available
// const variations = await apiFetch( {
// path: '/wc/v3/reports/variations' + params,
// } );
const variations = await fetch(
'https://virtserver.swaggerhub.com/peterfabian/wc-v3-api/1.0.0/reports/variations' + params
);
const data = await variations.json();
dispatch( 'wc-admin' ).setVariations( data, query );
} catch ( error ) {
dispatch( 'wc-admin' ).setVariationsError( query );
}
},
};

View File

@ -0,0 +1,49 @@
/** @format */
/**
* External dependencies
*/
import { get } from 'lodash';
import { select } from '@wordpress/data';
/**
* Internal dependencies
*/
import { ERROR } from 'store/constants';
import { getJsonString } from 'store/utils';
/**
* Returns products report details for a specific report query.
*
* @param {Object} state Current state
* @param {Object} query Report query paremters
* @return {Object} Report details
*/
function getVariations( state, query = {} ) {
return get( state, [ 'variations', getJsonString( query ) ], [] );
}
export default {
getVariations,
/**
* Returns true if a products request is pending.
*
* @param {Object} state Current state
* @return {Object} True if the `getProducts` request is pending, false otherwise
*/
isGetVariationsRequesting( state, ...args ) {
return select( 'core/data' ).isResolving( 'wc-admin', 'getVariations', args );
},
/**
* Returns true if a products request has returned an error.
*
* @param {Object} state Current state
* @param {Object} query Report query paremters
* @return {Object} True if the `getProducts` request has failed, false otherwise
*/
isGetVariationsError( state, query ) {
return ERROR === getVariations( state, query );
},
};

View File

@ -0,0 +1,96 @@
/**
* @format
*/
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { ERROR } from 'store/constants';
import variationsReducer, { DEFAULT_STATE } from '../reducer';
import { getJsonString } from 'store/utils';
describe( 'variationsReducer', () => {
it( 'returns default state by default', () => {
const state = variationsReducer( undefined, {} );
expect( state ).toEqual( DEFAULT_STATE );
} );
it( 'returns received variations data', () => {
const originalState = deepFreeze( { ...DEFAULT_STATE } );
const query = {
page: 3,
per_page: 5,
};
const variations = [
{
id: 1,
attributes: [],
},
];
const state = variationsReducer( originalState, {
type: 'SET_VARIATIONS',
query,
variations,
} );
const queryKey = getJsonString( query );
expect( state[ queryKey ] ).toEqual( variations );
} );
it( 'returns received variations data for multiple queries', () => {
const originalState = deepFreeze( { ...DEFAULT_STATE } );
const query1 = {
page: 3,
per_page: 5,
};
const variations1 = [
{
id: 1,
attributes: [],
},
];
const intermediateState = variationsReducer( originalState, {
type: 'SET_VARIATIONS',
query: query1,
variations: variations1,
} );
const query2 = {
page: 6232,
per_page: 978,
};
const variations2 = [
{
id: 2,
name: 'my-other-product',
},
];
const finalState = variationsReducer( intermediateState, {
type: 'SET_VARIATIONS',
query: query2,
variations: variations2,
} );
const queryKey1 = getJsonString( query1 );
const queryKey2 = getJsonString( query2 );
expect( finalState[ queryKey1 ] ).toEqual( variations1 );
expect( finalState[ queryKey2 ] ).toEqual( variations2 );
} );
it( 'returns error appropriately', () => {
const originalState = deepFreeze( { ...DEFAULT_STATE } );
const query = {
page: 4,
per_page: 5,
};
const state = variationsReducer( originalState, {
type: 'SET_VARIATIONS_ERROR',
query,
} );
const queryKey = getJsonString( query );
expect( state[ queryKey ] ).toEqual( ERROR );
} );
} );

View File

@ -0,0 +1,52 @@
/*
* @format
*/
// @TODO: Create these tests when /reports/variations becomes available
// /**
// * External dependencies
// */
// import apiFetch from '@wordpress/api-fetch';
// import { dispatch } from '@wordpress/data';
//
// /**
// * Internal dependencies
// */
// import resolvers from '../resolvers';
//
// const { getVariations } = resolvers;
jest.mock( '@wordpress/data', () => ( {
dispatch: jest.fn().mockReturnValue( {
setVariations: jest.fn(),
} ),
} ) );
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
describe( 'getVariations', () => {
// const variations = [
// {
// id: 3,
// attributes: [],
// },
// {
// id: 4,
// attributes: [],
// },
// ];
//
// beforeAll( () => {
// apiFetch.mockImplementation( options => {
// if ( options.path === '/wc/v3/products/47/variations' ) {
// return Promise.resolve( variations );
// }
// } );
// } );
it( 'returns requested variations', async () => {
// // expect.assertions( 1 );
// await getVariations( {}, { products: '47' } );
// expect( dispatch().setVariations ).toHaveBeenCalledWith( variations, { products: '47' } );
} );
} );

View File

@ -0,0 +1,96 @@
/*
* @format
*/
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { ERROR } from 'store/constants';
import selectors from '../selectors';
import { select } from '@wordpress/data';
import { getJsonString } from 'store/utils';
const { getVariations, isGetVariationsRequesting, isGetVariationsError } = selectors;
jest.mock( '@wordpress/data', () => ( {
...require.requireActual( '@wordpress/data' ),
select: jest.fn().mockReturnValue( {} ),
} ) );
const query = { orderby: 'date' };
const queryKey = getJsonString( query );
describe( 'getVariations', () => {
it( 'returns an empty array when no query matches values in state', () => {
const state = deepFreeze( {} );
expect( getVariations( state, query ) ).toEqual( [] );
} );
it( 'returns variations for a given query', () => {
const variations = [
{
id: 1,
attributes: [],
},
];
const state = deepFreeze( {
variations: {
[ queryKey ]: variations,
},
} );
expect( getVariations( state, query ) ).toEqual( variations );
} );
} );
describe( 'isGetVariationsRequesting', () => {
beforeAll( () => {
select( 'core/data' ).isResolving = jest.fn().mockReturnValue( false );
} );
afterAll( () => {
select( 'core/data' ).isResolving.mockRestore();
} );
function setIsResolving( isResolving ) {
select( 'core/data' ).isResolving.mockImplementation(
( reducerKey, selectorName ) =>
isResolving && reducerKey === 'wc-admin' && selectorName === 'getVariations'
);
}
it( 'returns false if never requested', () => {
const result = isGetVariationsRequesting( query );
expect( result ).toBe( false );
} );
it( 'returns false if request finished', () => {
setIsResolving( false );
const result = isGetVariationsRequesting( query );
expect( result ).toBe( false );
} );
it( 'returns true if requesting', () => {
setIsResolving( true );
const result = isGetVariationsRequesting( query );
expect( result ).toBe( true );
} );
} );
describe( 'isGetVariationsError', () => {
it( 'returns false by default', () => {
const state = deepFreeze( {} );
expect( isGetVariationsError( state, query ) ).toEqual( false );
} );
it( 'returns true if ERROR constant is found', () => {
const state = deepFreeze( {
variations: {
[ queryKey ]: ERROR,
},
} );
expect( isGetVariationsError( state, query ) ).toEqual( true );
} );
} );