Add Product Detail Report & Finish Comparison Chart Mode Functionality (https://github.com/woocommerce/woocommerce-admin/pull/1391)

Product detail report and comparison

Code cleanup

Fix 404 on product variations endpoint after rebase

Fix up tests

Fix loading indicators and add some checks around malformed variation response

Add date filtering SQL bits to the variations query

Handle PR feedback: Fix viewport, fix duplicate product issue, fix legend display reset, fix bargraph overflow, fix some coding standards/whitespace, add extra variation santiziation.

Fix scroll on wide charts, and fix undefined prop getting passed into report chart render
This commit is contained in:
Justin Shreve 2019-02-05 13:12:58 -05:00 committed by GitHub
parent ff129d948c
commit 949afce248
27 changed files with 625 additions and 202 deletions

View File

@ -6,12 +6,10 @@ import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { format as formatDate } from '@wordpress/date';
import PropTypes from 'prop-types';
import { find, get } from 'lodash';
/**
* WooCommerce dependencies
*/
import { flattenFilters } from '@woocommerce/navigation';
import {
getAllowedIntervalsForQuery,
getCurrentDates,
@ -28,66 +26,37 @@ import { Chart } from '@woocommerce/components';
import { getReportChartData, getTooltipValueFormat } from 'wc-api/reports/utils';
import ReportError from 'analytics/components/report-error';
import withSelect from 'wc-api/with-select';
export const DEFAULT_FILTER = 'all';
import { getChartMode } from './utils';
/**
* Component that renders the chart in reports.
*/
export class ReportChart extends Component {
getSelectedFilter( filters, query ) {
if ( filters.length === 0 ) {
return null;
}
const filterConfig = filters.pop();
if ( filterConfig.showFilters( query ) ) {
const allFilters = flattenFilters( filterConfig.filters );
const value = query[ filterConfig.param ] || DEFAULT_FILTER;
const selectedFilter = find( allFilters, { value } );
const selectedFilterParam = get( selectedFilter, [ 'settings', 'param' ] );
if ( ! selectedFilterParam || Object.keys( query ).includes( selectedFilterParam ) ) {
return selectedFilter;
}
}
return this.getSelectedFilter( filters, query );
getItemChartData() {
const { primaryData, selectedChart } = this.props;
const chartData = primaryData.data.intervals.map( function( interval ) {
const intervalData = {};
interval.subtotals.segments.forEach( function( segment ) {
if ( segment.segment_label ) {
const label = intervalData[ segment.segment_label ]
? segment.segment_label + ' (#' + segment.segment_id + ')'
: segment.segment_label;
intervalData[ label ] = {
value: segment.subtotals[ selectedChart.key ] || 0,
};
}
} );
return {
date: formatDate( 'Y-m-d\\TH:i:s', interval.date_start ),
...intervalData,
};
} );
return chartData;
}
getChartMode() {
const { filters, query } = this.props;
if ( ! filters ) {
return;
}
const clonedFilters = filters.slice( 0 );
const selectedFilter = this.getSelectedFilter( clonedFilters, query );
return get( selectedFilter, [ 'chartMode' ] );
}
render() {
const {
interactiveLegend,
itemsLabel,
legendPosition,
mode,
path,
primaryData,
query,
secondaryData,
selectedChart,
showHeaderControls,
} = this.props;
if ( primaryData.isError || secondaryData.isError ) {
return <ReportError isError />;
}
getTimeChartData() {
const { query, primaryData, secondaryData, selectedChart } = this.props;
const currentInterval = getIntervalForQuery( query );
const allowedIntervals = getAllowedIntervalsForQuery( query );
const formats = getDateFormatsForInterval( currentInterval, primaryData.data.intervals.length );
const { primary, secondary } = getCurrentDates( query );
const primaryKey = `${ primary.label } (${ primary.range })`;
const secondaryKey = `${ secondary.label } (${ secondary.range })`;
@ -115,6 +84,23 @@ export class ReportChart extends Component {
};
} );
return chartData;
}
renderChart( mode, isRequesting, chartData ) {
const {
interactiveLegend,
itemsLabel,
legendPosition,
path,
query,
selectedChart,
showHeaderControls,
primaryData,
} = this.props;
const currentInterval = getIntervalForQuery( query );
const allowedIntervals = getAllowedIntervalsForQuery( query );
const formats = getDateFormatsForInterval( currentInterval, primaryData.data.intervals.length );
return (
<Chart
allowedIntervals={ allowedIntervals }
@ -122,16 +108,16 @@ export class ReportChart extends Component {
dateParser={ '%Y-%m-%dT%H:%M:%S' }
interactiveLegend={ interactiveLegend }
interval={ currentInterval }
isRequesting={ primaryData.isRequesting || secondaryData.isRequesting }
isRequesting={ isRequesting }
itemsLabel={ itemsLabel }
legendPosition={ legendPosition }
mode={ mode || this.getChartMode() }
mode={ mode }
path={ path }
query={ query }
showHeaderControls={ showHeaderControls }
title={ selectedChart.label }
tooltipLabelFormat={ formats.tooltipLabelFormat }
tooltipTitle={ selectedChart.label }
tooltipTitle={ ( 'time-comparison' === mode && selectedChart.label ) || null }
tooltipValueFormat={ getTooltipValueFormat( selectedChart.type ) }
type={ getChartTypeForQuery( query ) }
valueType={ selectedChart.type }
@ -140,6 +126,40 @@ export class ReportChart extends Component {
/>
);
}
renderItemComparison() {
const { primaryData } = this.props;
if ( primaryData.isError ) {
return <ReportError isError />;
}
const isRequesting = primaryData.isRequesting;
const chartData = this.getItemChartData();
return this.renderChart( 'item-comparison', isRequesting, chartData );
}
renderTimeComparison() {
const { primaryData, secondaryData } = this.props;
if ( ! primaryData || primaryData.isError || secondaryData.isError ) {
return <ReportError isError />;
}
const isRequesting = primaryData.isRequesting || secondaryData.isRequesting;
const chartData = this.getTimeChartData();
return this.renderChart( 'time-comparison', isRequesting, chartData );
}
render() {
const { mode } = this.props;
if ( 'item-comparison' === mode ) {
return this.renderItemComparison();
}
return this.renderTimeComparison();
}
}
ReportChart.propTypes = {
@ -171,7 +191,7 @@ ReportChart.propTypes = {
/**
* Secondary data to display in the chart.
*/
secondaryData: PropTypes.object.isRequired,
secondaryData: PropTypes.object,
/**
* Properties of the selected chart.
*/
@ -180,10 +200,21 @@ ReportChart.propTypes = {
export default compose(
withSelect( ( select, props ) => {
const { query, endpoint } = props;
const { query, endpoint, filters } = props;
const chartMode = props.mode || getChartMode( filters, query ) || 'time-comparison';
if ( 'item-comparison' === chartMode ) {
const primaryData = getReportChartData( endpoint, 'primary', query, select );
return {
mode: chartMode,
primaryData,
};
}
const primaryData = getReportChartData( endpoint, 'primary', query, select );
const secondaryData = getReportChartData( endpoint, 'secondary', query, select );
return {
mode: chartMode,
primaryData,
secondaryData,
};

View File

@ -8,6 +8,7 @@ import { shallow } from 'enzyme';
* Internal dependencies
*/
import { ReportChart } from '../';
import { getChartMode } from '../utils';
jest.mock( '@woocommerce/components', () => ( {
...require.requireActual( '@woocommerce/components' ),
@ -30,7 +31,7 @@ const selectedChart = {
};
describe( 'ReportChart', () => {
test( 'should not set the mode prop by default', () => {
test( 'should set the time-comparison mode prop by default', () => {
const reportChart = shallow(
<ReportChart
path={ path }
@ -42,7 +43,7 @@ describe( 'ReportChart', () => {
);
const chart = reportChart.find( 'Chart' );
expect( chart.props().mode ).toBeUndefined();
expect( chart.props().mode ).toEqual( 'time-comparison' );
} );
test( 'should set the mode prop depending on the active filter', () => {
@ -62,19 +63,7 @@ describe( 'ReportChart', () => {
},
];
const query = { filter: 'lorem-ipsum', filter2: 'ipsum-lorem' };
const reportChart = shallow(
<ReportChart
filters={ filters }
path={ path }
primaryData={ data }
query={ query }
secondaryData={ data }
selectedChart={ selectedChart }
/>
);
const chart = reportChart.find( 'Chart' );
expect( chart.props().mode ).toEqual( 'item-comparison' );
const mode = getChartMode( filters, query );
expect( mode ).toEqual( 'item-comparison' );
} );
} );

View File

@ -0,0 +1,44 @@
/** @format */
/**
* External dependencies
*/
import { find, get } from 'lodash';
/**
* WooCommerce dependencies
*/
import { flattenFilters } from '@woocommerce/navigation';
export const DEFAULT_FILTER = 'all';
export function getSelectedFilter( filters, query, selectedFilterArgs = {} ) {
if ( filters.length === 0 ) {
return null;
}
const filterConfig = filters.pop();
if ( filterConfig.showFilters( query, selectedFilterArgs ) ) {
const allFilters = flattenFilters( filterConfig.filters );
const value = query[ filterConfig.param ] || DEFAULT_FILTER;
const selectedFilter = find( allFilters, { value } );
const selectedFilterParam = get( selectedFilter, [ 'settings', 'param' ] );
if ( ! selectedFilterParam || Object.keys( query ).includes( selectedFilterParam ) ) {
return selectedFilter;
}
}
return getSelectedFilter( filters, query, selectedFilterArgs );
}
export function getChartMode( filters, query, selectedFilterArgs ) {
if ( ! filters ) {
return;
}
const clonedFilters = filters.slice( 0 );
const selectedFilter = getSelectedFilter( clonedFilters, query, selectedFilterArgs );
return get( selectedFilter, [ 'chartMode' ] );
}

View File

@ -29,7 +29,7 @@ export const charts = [
const filterConfig = {
label: __( 'Show', 'wc-admin' ),
staticParams: [ 'chart' ],
staticParams: [],
param: 'filter',
showFilters: () => true,
filters: [
@ -114,19 +114,20 @@ const filterConfig = {
label: __( 'Top Products by Items Sold', 'wc-admin' ),
value: 'top_items',
chartMode: 'item-comparison',
query: { orderby: 'items_sold', order: 'desc' },
query: { orderby: 'items_sold', order: 'desc', chart: 'items_sold' },
},
{
label: __( 'Top Products by Net Revenue', 'wc-admin' ),
value: 'top_sales',
chartMode: 'item-comparison',
query: { orderby: 'net_revenue', order: 'desc' },
query: { orderby: 'net_revenue', order: 'desc', chart: 'net_revenue' },
},
],
};
const variationsConfig = {
showFilters: query => 'single_product' === query.filter && !! query.products,
showFilters: query =>
'single_product' === query.filter && !! query.products && query[ 'is-variable' ],
staticParams: [ 'filter', 'products' ],
param: 'filter-variations',
filters: [
@ -151,13 +152,13 @@ const variationsConfig = {
label: __( 'Top Variations by Items Sold', 'wc-admin' ),
chartMode: 'item-comparison',
value: 'top_items',
query: { orderby: 'items_sold', order: 'desc' },
query: { orderby: 'items_sold', order: 'desc', chart: 'item_sold' },
},
{
label: __( 'Top Variations by Net Revenue', 'wc-admin' ),
chartMode: 'item-comparison',
value: 'top_sales',
query: { orderby: 'net_revenue', order: 'desc' },
query: { orderby: 'net_revenue', order: 'desc', chart: 'net_revenue' },
},
],
};

View File

@ -4,12 +4,13 @@
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import PropTypes from 'prop-types';
/**
* WooCommerce dependencies
*/
import { ReportFilters } from '@woocommerce/components';
import { ReportFilters, SummaryListPlaceholder, ChartPlaceholder } from '@woocommerce/components';
/**
* Internal dependencies
@ -18,37 +19,99 @@ import { charts, filters } from './config';
import getSelectedChart from 'lib/get-selected-chart';
import ProductsReportTable from './table';
import ReportChart from 'analytics/components/report-chart';
import ReportError from 'analytics/components/report-error';
import ReportSummary from 'analytics/components/report-summary';
import VariationsReportTable from './table-variations';
import withSelect from 'wc-api/with-select';
class ProductsReport extends Component {
getChartMeta() {
const { query, isSingleProductView, isSingleProductVariable } = this.props;
const isProductDetailsView =
'top_items' === query.filter ||
'top_sales' === query.filter ||
'single_product' === query.filter;
const mode =
isProductDetailsView || ( isSingleProductView && isSingleProductVariable )
? 'item-comparison'
: 'time-comparison';
const compareObject =
isSingleProductView && isSingleProductVariable ? 'variations' : 'products';
const label =
isSingleProductView && isSingleProductVariable
? __( '%s variations', 'wc-admin' )
: __( '%s products', 'wc-admin' );
return {
compareObject,
itemsLabel: label,
mode,
};
}
export default class ProductsReport extends Component {
render() {
const { path, query } = this.props;
const isProductDetailsView = query.filter === 'single_product';
const { compareObject, itemsLabel, mode } = this.getChartMeta();
const {
path,
query,
isProductsError,
isProductsRequesting,
isSingleProductVariable,
} = this.props;
const itemsLabel = isProductDetailsView
? __( '%s variations', 'wc-admin' )
: __( '%s products', 'wc-admin' );
if ( isProductsError ) {
return <ReportError isError />;
}
if ( isProductsRequesting ) {
return (
<Fragment>
<ReportFilters query={ query } path={ path } filters={ filters } />
<SummaryListPlaceholder numberOfItems={ charts.length } />
<span className="screen-reader-text">
{ __( 'Your requested data is loading', 'wc-admin' ) }
</span>
<div className="woocommerce-chart">
<div className="woocommerce-chart__body">
<ChartPlaceholder height={ 220 } />
</div>
</div>
<ProductsReportTable query={ query } />
</Fragment>
);
}
const chartQuery = {
...query,
};
if ( 'item-comparison' === mode ) {
chartQuery.segmentby = 'products' === compareObject ? 'product' : 'variation';
}
return (
<Fragment>
<ReportFilters query={ query } path={ path } filters={ filters } />
<ReportSummary
mode={ mode }
charts={ charts }
endpoint="products"
query={ query }
query={ chartQuery }
selectedChart={ getSelectedChart( query.chart, charts ) }
/>
<ReportChart
mode={ mode }
filters={ filters }
charts={ charts }
endpoint="products"
itemsLabel={ itemsLabel }
path={ path }
query={ query }
selectedChart={ getSelectedChart( query.chart, charts ) }
query={ chartQuery }
selectedChart={ getSelectedChart( chartQuery.chart, charts ) }
/>
{ isProductDetailsView ? (
{ isSingleProductVariable ? (
<VariationsReportTable query={ query } />
) : (
<ProductsReportTable query={ query } />
@ -62,3 +125,34 @@ ProductsReport.propTypes = {
path: PropTypes.string.isRequired,
query: PropTypes.object.isRequired,
};
export default compose(
withSelect( ( select, props ) => {
const { query } = props;
const { getProducts, isGetProductsRequesting, getProductsError } = select( 'wc-api' );
const isSingleProductView = query.products && 1 === query.products.split( ',' ).length;
if ( isSingleProductView ) {
const includeArgs = { include: query.products };
// TODO Look at similar usage to populate tags in the Search component.
const products = getProducts( includeArgs );
const isVariable = products[ 0 ] && 'variable' === products[ 0 ].type;
const isProductsRequesting = isGetProductsRequesting( includeArgs );
const isProductsError = getProductsError( includeArgs );
return {
query: {
...query,
'is-variable': isVariable,
},
isSingleProductView,
isSingleProductVariable: isVariable,
isProductsRequesting,
isProductsError,
};
}
return {
query,
isSingleProductView,
};
} )
)( ProductsReport );

View File

@ -139,12 +139,11 @@ export default class VariationsReportTable extends Component {
}
getSummary( totals ) {
const { products_count = 0, items_sold = 0, net_revenue = 0, orders_count = 0 } = totals;
const { variations_count = 0, items_sold = 0, net_revenue = 0, orders_count = 0 } = totals;
return [
{
// @TODO: When primaryData is segmented, fix this to reflect variations, not products.
label: _n( 'variation sold', 'variations sold', products_count, 'wc-admin' ),
value: numberFormat( products_count ),
label: _n( 'variation sold', 'variations sold', variations_count, 'wc-admin' ),
value: numberFormat( variations_count ),
},
{
label: _n( 'item sold', 'items sold', items_sold, 'wc-admin' ),
@ -176,7 +175,7 @@ export default class VariationsReportTable extends Component {
endpoint="variations"
getHeadersContent={ this.getHeadersContent }
getRowsContent={ this.getRowsContent }
itemIdField="product_id"
itemIdField="variation_id"
labels={ labels }
query={ query }
getSummary={ this.getSummary }

View File

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

View File

@ -0,0 +1,51 @@
/** @format */
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* WooCommerce dependencies
*/
import { stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { isResourcePrefix, getResourceIdentifier, getResourceName } from '../utils';
function read( resourceNames, fetch = apiFetch ) {
const filteredNames = resourceNames.filter( name => isResourcePrefix( name, 'product-query' ) );
return filteredNames.map( async resourceName => {
const query = getResourceIdentifier( resourceName );
const url = `/wc/v3/products${ stringifyQuery( query ) }`;
try {
const products = await fetch( {
path: url,
} );
const ids = products.map( product => product.id );
const productResources = products.reduce( ( resources, product ) => {
resources[ getResourceName( 'product', product.id ) ] = { data: product };
return resources;
}, {} );
return {
[ resourceName ]: {
data: ids,
totalCount: ids.length,
},
...productResources,
};
} catch ( error ) {
return { [ resourceName ]: { error } };
}
} );
}
export default {
read,
};

View File

@ -0,0 +1,44 @@
/** @format */
/**
* External dependencies
*/
import { isNil } from 'lodash';
/**
* Internal dependencies
*/
import { getResourceName } from '../utils';
import { DEFAULT_REQUIREMENT } from '../constants';
const getProducts = ( getResource, requireResource ) => (
query = {},
requirement = DEFAULT_REQUIREMENT
) => {
const resourceName = getResourceName( 'product-query', query );
const ids = requireResource( requirement, resourceName ).data || [];
const products = ids.map( id => getResource( getResourceName( 'product', id ) ).data || {} );
return products;
};
const getProductsError = getResource => ( query = {} ) => {
const resourceName = getResourceName( 'product-query', query );
return getResource( resourceName ).error;
};
const isGetProductsRequesting = getResource => ( query = {} ) => {
const resourceName = getResourceName( 'product-query', query );
const { lastRequested, lastReceived } = getResource( resourceName );
if ( isNil( lastRequested ) || isNil( lastReceived ) ) {
return true;
}
return lastRequested > lastReceived;
};
export default {
getProducts,
getProductsError,
isGetProductsRequesting,
};

View File

@ -168,14 +168,13 @@ function getRequestQuery( endpoint, dataType, query ) {
const interval = getIntervalForQuery( query );
const filterQuery = getFilterQuery( endpoint, query );
const end = datesFromQuery[ dataType ].before;
const endingTimeOfDay = end.isSame( moment(), 'day' ) ? 'now' : 'end';
return {
order: 'asc',
interval,
per_page: MAX_PER_PAGE,
after: appendTimestamp( datesFromQuery[ dataType ].after, 'start' ),
before: appendTimestamp( end, endingTimeOfDay ),
before: appendTimestamp( end, 'end' ),
segmentby: query.segmentby,
...filterQuery,
};
}

View File

@ -5,6 +5,7 @@
*/
import items from './items';
import notes from './notes';
import products from './products';
import reportItems from './reports/items';
import reportStats from './reports/stats';
import reviews from './reviews';
@ -20,6 +21,7 @@ function createWcApiSpec() {
selectors: {
...items.selectors,
...notes.selectors,
...products.selectors,
...reportItems.selectors,
...reportStats.selectors,
...reviews.selectors,
@ -31,6 +33,7 @@ function createWcApiSpec() {
return [
...items.operations.read( resourceNames ),
...notes.operations.read( resourceNames ),
...products.operations.read( resourceNames ),
...reportItems.operations.read( resourceNames ),
...reportStats.operations.read( resourceNames ),
...reviews.operations.read( resourceNames ),

View File

@ -60,6 +60,7 @@ class WC_Admin_REST_Reports_Products_Stats_Controller extends WC_REST_Reports_Co
'net_revenue',
'orders_count',
'products_count',
'variations_count',
),
);
@ -188,6 +189,13 @@ class WC_Admin_REST_Reports_Products_Stats_Controller extends WC_REST_Reports_Co
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'segment_label' => array(
'description' => __( 'Human readable segment label, either product or variation name.', 'wc-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'wc-admin' ),
'type' => 'object',
@ -383,6 +391,15 @@ class WC_Admin_REST_Reports_Products_Stats_Controller extends WC_REST_Reports_Co
'type' => 'integer',
),
);
$params['variations'] = array(
'description' => __( 'Limit result to items with specified variation ids.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'wc-admin' ),
'type' => 'string',

View File

@ -290,7 +290,7 @@ class WC_Admin_REST_Reports_Variations_Controller extends WC_REST_Reports_Contro
'default' => 'date',
'enum' => array(
'date',
'gross_revenue',
'net_revenue',
'orders_count',
'items_sold',
),

View File

@ -180,6 +180,7 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-orders-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-products-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-categories-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-variations-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-reviews-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-variations-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-controller.php';
@ -217,6 +218,7 @@ class WC_Admin_Api_Init {
'WC_Admin_REST_Orders_Controller',
'WC_Admin_REST_Products_Controller',
'WC_Admin_REST_Product_Categories_Controller',
'WC_Admin_REST_Product_Variations_Controller',
'WC_Admin_REST_Product_Reviews_Controller',
'WC_Admin_REST_Product_Variations_Controller',
'WC_Admin_REST_Reports_Controller',

View File

@ -21,10 +21,11 @@ class WC_Admin_Reports_Products_Stats_Segmenting extends WC_Admin_Reports_Segmen
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'items_sold' => "SUM($products_table.product_qty) as items_sold",
'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue",
'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
'products_count' => "COUNT( DISTINCT $products_table.product_id ) AS products_count",
'items_sold' => "SUM($products_table.product_qty) as items_sold",
'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue",
'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
'products_count' => "COUNT( DISTINCT $products_table.product_id ) AS products_count",
'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count",
);
return $this->prepare_selections( $columns_mapping );

View File

@ -19,6 +19,13 @@ class WC_Admin_Reports_Segmenting {
*/
protected $all_segment_ids = false;
/**
* Array of all segment labels.
*
* @var array
*/
protected $segment_labels = array();
/**
* Query arguments supplied by the user for data store.
*
@ -88,10 +95,12 @@ class WC_Admin_Reports_Segmenting {
foreach ( $segments_db_result as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
$segment_labels = $this->get_segment_labels();
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
'segment_id' => $segment_id,
'segment_label' => $segment_labels[ $segment_id ],
'subtotals' => $segment_data,
);
$segment_result[ $segment_id ] = $segment_datum;
}
@ -133,13 +142,15 @@ class WC_Admin_Reports_Segmenting {
*/
protected function merge_segment_totals_results( $segment_dimension, $result1, $result2 ) {
$result_segments = array();
$segment_labels = $this->get_segment_labels();
foreach ( $result1 as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
$result_segments[ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
}
@ -148,8 +159,9 @@ class WC_Admin_Reports_Segmenting {
unset( $segment_data[ $segment_dimension ] );
if ( ! isset( $result_segments[ $segment_id ] ) ) {
$result_segments[ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => array(),
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => array(),
);
}
$result_segments[ $segment_id ]['subtotals'] = array_merge( $result_segments[ $segment_id ]['subtotals'], $segment_data );
@ -196,6 +208,7 @@ class WC_Admin_Reports_Segmenting {
*/
protected function merge_segment_intervals_results( $segment_dimension, $result1, $result2 ) {
$result_segments = array();
$segment_labels = $this->get_segment_labels();
foreach ( $result1 as $segment_data ) {
$time_interval = $segment_data['time_interval'];
@ -208,8 +221,9 @@ class WC_Admin_Reports_Segmenting {
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
$result_segments[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
}
@ -227,8 +241,9 @@ class WC_Admin_Reports_Segmenting {
if ( ! isset( $result_segments[ $time_interval ]['segments'][ $segment_id ] ) ) {
$result_segments[ $time_interval ]['segments'][ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => array(),
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => array(),
);
}
$result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'] = array_merge( $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'], $segment_data );
@ -251,6 +266,8 @@ class WC_Admin_Reports_Segmenting {
$segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 );
}
$segment_labels = $this->get_segment_labels();
foreach ( $segments_db_result as $segment_data ) {
$time_interval = $segment_data['time_interval'];
if ( ! isset( $aggregated_segment_result[ $time_interval ] ) ) {
@ -262,8 +279,9 @@ class WC_Admin_Reports_Segmenting {
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
'segment_label' => $segment_labels[ $segment_id ],
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
$aggregated_segment_result[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
}
@ -284,13 +302,25 @@ class WC_Admin_Reports_Segmenting {
return;
}
$segments = array();
$segment_labels = array();
if ( 'product' === $this->query_args['segmentby'] ) {
$segments = wc_get_products(
array(
'return' => 'ids',
'limit' => -1,
)
$args = array(
'return' => 'objects',
'limit' => -1,
);
if ( isset( $this->query_args['product_includes'] ) ) {
$args['include'] = $this->query_args['product_includes'];
}
$segment_objects = wc_get_products( $args );
foreach ( $segment_objects as $segment ) {
$id = $segment->get_id();
$segments[] = $id;
$segment_labels[ $id ] = $segment->get_name();
}
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
// @todo: assuming that this will only be used for one product, check assumption.
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
@ -298,14 +328,24 @@ class WC_Admin_Reports_Segmenting {
return;
}
$segments = wc_get_products(
array(
'return' => 'ids',
'limit' => - 1,
'type' => 'variation',
'parent' => $this->query_args['product_includes'][0],
)
$args = array(
'return' => 'objects',
'limit' => -1,
'type' => 'variation',
'parent' => $this->query_args['product_includes'][0],
);
if ( isset( $this->query_args['variations'] ) ) {
$args['include'] = $this->query_args['variations'];
}
$segment_objects = wc_get_products( $args );
foreach ( $segment_objects as $segment ) {
$id = $segment->get_id();
$segments[] = $id;
$segment_labels[ $id ] = $segment->get_name();
}
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$categories = get_categories(
array(
@ -338,6 +378,7 @@ class WC_Admin_Reports_Segmenting {
}
$this->all_segment_ids = $segments;
$this->segment_labels = $segment_labels;
}
/**
@ -353,6 +394,19 @@ class WC_Admin_Reports_Segmenting {
return $this->all_segment_ids;
}
/**
* Return all segment labels for given segmentby query parameter.
*
* @return array
*/
protected function get_segment_labels() {
if ( ! is_array( $this->all_segment_ids ) ) {
$this->set_all_segments();
}
return $this->segment_labels;
}
/**
* Compares two report data objects by pre-defined object property and ASC/DESC ordering.
*
@ -395,11 +449,13 @@ class WC_Admin_Reports_Segmenting {
$segments = array();
}
$all_segment_ids = $this->get_all_segments();
$segment_labels = $this->get_segment_labels();
foreach ( $all_segment_ids as $segment_id ) {
if ( ! isset( $segments[ $segment_id ] ) ) {
$segments[ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => $segment_subtotals,
'segment_id' => $segment_id,
'segment_label' => $segment_labels[ $segment_id ],
'subtotals' => $segment_subtotals,
);
}
}

View File

@ -685,6 +685,24 @@ class WC_Admin_Reports_Data_Store {
return $included_products_str;
}
/**
* Returns comma separated ids of allowed variations, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_variations( $query_args ) {
$included_variations = array();
$operator = $this->get_match_operator( $query_args );
if ( isset( $query_args['variations'] ) && is_array( $query_args['variations'] ) && count( $query_args['variations'] ) > 0 ) {
$included_variations = array_filter( array_map( 'intval', $query_args['variations'] ) );
}
$included_variations_str = implode( ',', $included_variations );
return $included_variations_str;
}
/**
* Returns comma separated ids of excluded products, based on query arguments from the user.
*

View File

@ -133,6 +133,11 @@ class WC_Admin_Reports_Products_Data_Store extends WC_Admin_Reports_Data_Store i
$sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
}
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations ) {
$sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})";
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$sql_query_params['from_clause'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";

View File

@ -19,13 +19,14 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
* @var array
*/
protected $column_types = array(
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'products_count' => 'intval',
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'products_count' => 'intval',
'variations_count' => 'intval',
);
/**
@ -34,10 +35,11 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
* @var array
*/
protected $report_columns = array(
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => 'COUNT(DISTINCT order_id) as orders_count',
'products_count' => 'COUNT(DISTINCT product_id) as products_count',
'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => 'COUNT(DISTINCT order_id) as orders_count',
'products_count' => 'COUNT(DISTINCT product_id) as products_count',
'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count',
);
/**
@ -70,6 +72,11 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
$products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
}
$included_variations = $this->get_included_variations( $query_args );
if ( $included_variations ) {
$products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})";
}
$order_status_filter = $this->get_status_subquery( $query_args );
if ( $order_status_filter ) {
$products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id";

View File

@ -229,6 +229,7 @@ class WC_Admin_Reports_Variations_Data_Store extends WC_Admin_Reports_Data_Store
{$sql_query_params['from_clause']}
WHERE
1=1
{$sql_query_params['where_time_clause']}
{$sql_query_params['where_clause']}
GROUP BY
variation_id
@ -248,6 +249,7 @@ class WC_Admin_Reports_Variations_Data_Store extends WC_Admin_Reports_Data_Store
{$sql_query_params['from_clause']}
WHERE
1=1
{$sql_query_params['where_time_clause']}
{$sql_query_params['where_clause']}
GROUP BY
variation_id

View File

@ -2,6 +2,7 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { Component, createRef } from '@wordpress/element';
import PropTypes from 'prop-types';
@ -36,7 +37,7 @@ class D3Legend extends Component {
}
updateListScroll() {
if ( ! this.listRef ) {
if ( ! this || ! this.listRef ) {
return;
}
const list = this.listRef.current;
@ -93,7 +94,12 @@ class D3Legend extends Component {
<button
onClick={ handleLegendToggle }
id={ row.key }
disabled={ ( row.visible && numberOfRowsVisible <= 1 ) || ! interactive }
disabled={
( row.visible && numberOfRowsVisible <= 1 ) ||
( ! row.visible && numberOfRowsVisible >= 5 ) ||
! interactive
}
title={ numberOfRowsVisible >= 5 ? __( 'You may select up to 5 items.', 'wc-admin' ) : '' }
>
<div className="woocommerce-legend__item-container" id={ row.key }>
<span

View File

@ -4,9 +4,12 @@
*/
@import './legend.scss';
.woocommerce-chart__body-row .d3-chart__container {
width: calc( 100% - 320px );
}
.d3-chart__container {
position: relative;
width: 100%;
svg {
overflow: visible;

View File

@ -176,6 +176,11 @@ export const compareStrings = ( s1, s2, splitChar = new RegExp( [ ' |,' ], 'g' )
export const getYGrids = ( yMax ) => {
const yGrids = [];
// If all values are 0, yMax can become NaN.
if ( isNaN( yMax ) ) {
return null;
}
for ( let i = 0; i < 4; i++ ) {
const value = yMax > 1 ? Math.round( i / 3 * yMax ) : i / 3 * yMax;
if ( yGrids[ yGrids.length - 1 ] !== value ) {
@ -237,29 +242,31 @@ export const drawAxis = ( node, params, xOffset ) => {
.tickFormat( '' )
);
node
.append( 'g' )
.attr( 'class', 'grid' )
.attr( 'transform', `translate(-${ params.margin.left }, 0)` )
.call(
d3AxisLeft( params.yScale )
.tickValues( yGrids )
.tickSize( -params.width - params.margin.left - params.margin.right )
.tickFormat( '' )
)
.call( g => g.select( '.domain' ).remove() );
if ( yGrids ) {
node
.append( 'g' )
.attr( 'class', 'grid' )
.attr( 'transform', `translate(-${ params.margin.left }, 0)` )
.call(
d3AxisLeft( params.yScale )
.tickValues( yGrids )
.tickSize( -params.width - params.margin.left - params.margin.right )
.tickFormat( '' )
)
.call( g => g.select( '.domain' ).remove() );
node
.append( 'g' )
.attr( 'class', 'axis y-axis' )
.attr( 'aria-hidden', 'true' )
.attr( 'transform', 'translate(-50, 0)' )
.attr( 'text-anchor', 'start' )
.call(
d3AxisLeft( params.yTickOffset )
.tickValues( params.yMax === 0 ? [ yGrids[ 0 ] ] : yGrids )
.tickFormat( d => params.yFormat( d !== 0 ? d : 0 ) )
);
node
.append( 'g' )
.attr( 'class', 'axis y-axis' )
.attr( 'aria-hidden', 'true' )
.attr( 'transform', 'translate(-50, 0)' )
.attr( 'text-anchor', 'start' )
.call(
d3AxisLeft( params.yTickOffset )
.tickValues( params.yMax === 0 ? [ yGrids[ 0 ] ] : yGrids )
.tickFormat( d => params.yFormat( d !== 0 ? d : 0 ) )
);
}
node.selectAll( '.domain' ).remove();
node

View File

@ -6,7 +6,7 @@ import { __, sprintf } from '@wordpress/i18n';
import classNames from 'classnames';
import { Component, createRef, Fragment } from '@wordpress/element';
import { formatDefaultLocale as d3FormatDefaultLocale } from 'd3-format';
import { get, isEqual, partial } from 'lodash';
import { get, isEqual, partial, isEmpty } from 'lodash';
import Gridicon from 'gridicons';
import { IconButton, NavigableMenu, SelectControl } from '@wordpress/components';
import { interpolateViridis as d3InterpolateViridis } from 'd3-scale-chromatic';
@ -57,18 +57,29 @@ function getOrderedKeys( props, previousOrderedKeys = [] ) {
return accum;
}, [] )
),
].map( key => {
].map( ( key ) => {
const previousKey = previousOrderedKeys.find( item => key === item.key );
const defaultVisibleStatus = 'item-comparison' === props.mode ? false : true;
return {
key,
total: props.data.reduce( ( a, c ) => a + c[ key ].value, 0 ),
visible: previousKey ? previousKey.visible : true,
visible: previousKey ? previousKey.visible : defaultVisibleStatus,
focus: true,
};
} );
if ( props.mode === 'item-comparison' ) {
if ( 'item-comparison' === props.mode ) {
updatedKeys.sort( ( a, b ) => b.total - a.total );
if ( isEmpty( previousOrderedKeys ) ) {
return updatedKeys.filter( key => key.total > 0 ).map( ( key, index ) => {
return {
...key,
visible: index < 5 || key.visible,
};
} );
}
}
return updatedKeys;
}
@ -94,7 +105,7 @@ class Chart extends Component {
}
componentDidUpdate( prevProps ) {
const { data } = this.props;
const { data, query, mode } = this.props;
if ( ! isEqual( [ ...data ].sort(), [ ...prevProps.data ].sort() ) ) {
/**
* Only update the orderedKeys when data is present so that
@ -110,6 +121,16 @@ class Chart extends Component {
} );
/* eslint-enable react/no-did-update-set-state */
}
if ( 'item-comparison' === mode && ! isEqual( query, prevProps.query ) ) {
const orderedKeys = getOrderedKeys( this.props );
/* eslint-disable react/no-did-update-set-state */
this.setState( {
orderedKeys,
visibleData: this.getVisibleData( data, orderedKeys ),
} );
/* eslint-enable react/no-did-update-set-state */
}
}
componentDidMount() {
@ -267,7 +288,7 @@ class Chart extends Component {
const chartDirection = legendPosition === 'side' ? 'row' : 'column';
const chartHeight = this.getChartHeight();
const legend = (
const legend = isRequesting ? null : (
<D3Legend
colorScheme={ d3InterpolateViridis }
data={ orderedKeys }

View File

@ -45,8 +45,7 @@ import TableSummary from './summary';
class TableCard extends Component {
constructor( props ) {
super( props );
const { compareBy, query } = props;
const { query, compareBy } = this.props;
const showCols = props.headers.map( ( { key, hiddenByDefault } ) => ! hiddenByDefault && key ).filter( Boolean );
const selectedRows = query.filter ? getIdsFromQuery( query[ compareBy ] ) : [];
@ -143,11 +142,10 @@ class TableCard extends Component {
}
onCompare() {
const { compareBy, compareParam, onQueryChange } = this.props;
const { selectedRows } = this.state;
if ( compareBy ) {
onQueryChange( 'compare' )( compareBy, compareParam, selectedRows.join( ',' ) );
}
// Reset selected rows so the user can start a comparison again.
this.setState( {
selectedRows: [],
} );
}
onSearch( values ) {

View File

@ -87,11 +87,12 @@ class WC_Tests_API_Reports_Products_Stats extends WC_REST_Unit_Test_Case {
$expected_reports = array(
'totals' => array(
'items_sold' => 4,
'net_revenue' => 100.0,
'orders_count' => 1,
'products_count' => 1,
'segments' => array(),
'items_sold' => 4,
'net_revenue' => 100.0,
'orders_count' => 1,
'products_count' => 1,
'variations_count' => 1,
'segments' => array(),
),
'intervals' => array(
array(
@ -101,11 +102,12 @@ class WC_Tests_API_Reports_Products_Stats extends WC_REST_Unit_Test_Case {
'date_end' => date( 'Y-m-d 23:59:59', $time ),
'date_end_gmt' => date( 'Y-m-d 23:59:59', $time ),
'subtotals' => (object) array(
'items_sold' => 4,
'net_revenue' => 100.0,
'orders_count' => 1,
'products_count' => 1,
'segments' => array(),
'items_sold' => 4,
'net_revenue' => 100.0,
'orders_count' => 1,
'products_count' => 1,
'variations_count' => 1,
'segments' => array(),
),
),
),

View File

@ -3465,7 +3465,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
'products' => 2,
'segments' => array(
array(
'segment_id' => $product_1->get_id(),
'segment_id' => $product_1->get_id(),
'segment_label' => $product_1->get_name(),
'subtotals' => array(
'orders_count' => $p1_orders_count,
'num_items_sold' => $p1_num_items_sold,
@ -3482,7 +3483,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_2->get_id(),
'segment_id' => $product_2->get_id(),
'segment_label' => $product_2->get_name(),
'subtotals' => array(
'orders_count' => $p2_orders_count,
'num_items_sold' => $p2_num_items_sold,
@ -3499,7 +3501,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_3->get_id(),
'segment_id' => $product_3->get_id(),
'segment_label' => $product_3->get_name(),
'subtotals' => array(
'orders_count' => 0,
'num_items_sold' => 0,
@ -3539,7 +3542,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
'num_new_customers' => $i3_tot_new_customers,
'segments' => array(
array(
'segment_id' => $product_1->get_id(),
'segment_id' => $product_1->get_id(),
'segment_label' => $product_1->get_name(),
'subtotals' => array(
'orders_count' => $i3_p1_orders_count,
'num_items_sold' => $i3_p1_num_items_sold,
@ -3556,7 +3560,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_2->get_id(),
'segment_id' => $product_2->get_id(),
'segment_label' => $product_2->get_name(),
'subtotals' => array(
'orders_count' => $i3_p2_orders_count,
'num_items_sold' => $i3_p2_num_items_sold,
@ -3573,7 +3578,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_3->get_id(),
'segment_id' => $product_3->get_id(),
'segment_label' => $product_3->get_name(),
'subtotals' => array(
'orders_count' => 0,
'num_items_sold' => 0,
@ -3613,7 +3619,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
'num_new_customers' => $i2_tot_new_customers,
'segments' => array(
array(
'segment_id' => $product_1->get_id(),
'segment_id' => $product_1->get_id(),
'segment_label' => $product_1->get_name(),
'subtotals' => array(
'orders_count' => $i2_p1_orders_count,
'num_items_sold' => $i2_p1_num_items_sold,
@ -3630,7 +3637,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_2->get_id(),
'segment_id' => $product_2->get_id(),
'segment_label' => $product_2->get_name(),
'subtotals' => array(
'orders_count' => $i2_p2_orders_count,
'num_items_sold' => $i2_p2_num_items_sold,
@ -3647,7 +3655,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_3->get_id(),
'segment_id' => $product_3->get_id(),
'segment_label' => $product_3->get_name(),
'subtotals' => array(
'orders_count' => 0,
'num_items_sold' => 0,
@ -3687,7 +3696,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
'num_new_customers' => 0,
'segments' => array(
array(
'segment_id' => $product_1->get_id(),
'segment_id' => $product_1->get_id(),
'segment_label' => $product_1->get_name(),
'subtotals' => array(
'orders_count' => 0,
'num_items_sold' => 0,
@ -3704,7 +3714,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_2->get_id(),
'segment_id' => $product_2->get_id(),
'segment_label' => $product_2->get_name(),
'subtotals' => array(
'orders_count' => 0,
'num_items_sold' => 0,
@ -3721,7 +3732,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case {
),
),
array(
'segment_id' => $product_3->get_id(),
'segment_id' => $product_3->get_id(),
'segment_label' => $product_3->get_name(),
'subtotals' => array(
'orders_count' => 0,
'num_items_sold' => 0,