Merge branch 'master' into fix/489-date_date_gmt

# Conflicts:
#	includes/class-wc-admin-api-init.php
This commit is contained in:
Peter Fabian 2019-02-15 17:07:38 +01:00
commit f7d0d2379f
114 changed files with 3140 additions and 1776 deletions

View File

@ -26,6 +26,7 @@ wp-cli.local.yml
yarn.lock
tests
vendor
config
node_modules
*.sql
*.tar.gz

View File

@ -8,6 +8,7 @@ build-style
languages/*
!languages/README.md
wc-admin.zip
includes/feature-config.php
# Directories/files that may appear in your environment
.DS_Store

View File

@ -63,6 +63,7 @@ fi
# Run the build.
status "Generating build... 👷‍♀️"
npm run build:feature-config
npm run build
npm run docs
@ -79,6 +80,6 @@ zip -r wc-admin.zip \
$build_files \
languages/wc-admin.pot \
languages/wc-admin.php \
README.md
readme.txt
success "Done. You've built WooCommerce Admin! 🎉 "

View File

@ -0,0 +1,28 @@
<?php
/**
* Generates an array of feature flags, based on the config used by the client application.
*
* @package WooCommerce Admin
*/
$phase = isset( $_SERVER['WC_ADMIN_PHASE'] ) ? $_SERVER['WC_ADMIN_PHASE'] : ''; // WPCS: sanitization ok.
if ( ! in_array( $phase, array( 'development', 'plugin', 'core' ) ) ) {
$phase = 'core';
}
$config_json = file_get_contents( 'config/' . $phase . '.json' );
$config = json_decode( $config_json );
$write = "<?php\n";
$write .= "// WARNING: Do not directly edit this file.\n";
$write .= "// This file is auto-generated as part of the build process and things may break.\n";
$write .= "function wc_admin_get_feature_config() {\n";
$write .= "\treturn array(\n";
foreach ( $config->features as $feature => $bool ) {
$write .= "\t\t'{$feature}' => " . ( $bool ? 'true' : 'false' ) . ",\n";
}
$write .= "\t);\n";
$write .= "}\n";
$config_file = fopen( 'includes/feature-config.php', 'w' );
fwrite( $config_file, $write );
fclose( $config_file );

View File

@ -2,6 +2,7 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { format as formatDate } from '@wordpress/date';
@ -89,6 +90,7 @@ export class ReportChart extends Component {
renderChart( mode, isRequesting, chartData ) {
const {
emptySearchResults,
interactiveLegend,
itemsLabel,
legendPosition,
@ -101,11 +103,15 @@ export class ReportChart extends Component {
const currentInterval = getIntervalForQuery( query );
const allowedIntervals = getAllowedIntervalsForQuery( query );
const formats = getDateFormatsForInterval( currentInterval, primaryData.data.intervals.length );
const emptyMessage = emptySearchResults
? __( 'No data for the current search', 'wc-admin' )
: __( 'No data for the selected date range', 'wc-admin' );
return (
<Chart
allowedIntervals={ allowedIntervals }
data={ chartData }
dateParser={ '%Y-%m-%dT%H:%M:%S' }
emptyMessage={ emptyMessage }
interactiveLegend={ interactiveLegend }
interval={ currentInterval }
isRequesting={ isRequesting }
@ -222,6 +228,7 @@ export default compose(
if ( query.search && ! ( query[ endpoint ] && query[ endpoint ].length ) ) {
return {
emptySearchResults: true,
mode: chartMode,
};
}

View File

@ -61,8 +61,15 @@ export class ReportSummary extends Component {
const renderSummaryNumbers = ( { onToggle } ) =>
charts.map( chart => {
const { key, label, type } = chart;
const href = getNewPath( { chart: key } );
const { key, order, orderby, label, type } = chart;
const newPath = { chart: key };
if ( orderby ) {
newPath.orderby = orderby;
}
if ( order ) {
newPath.order = order;
}
const href = getNewPath( newPath );
const isSelected = selectedChart.key === key;
const { delta, prevValue, value } = this.getValues( key, type );

View File

@ -194,7 +194,9 @@ export default compose(
if ( query.search && ! ( query[ endpoint ] && query[ endpoint ].length ) ) {
return {};
}
const chartEndpoint = 'variations' === endpoint ? 'products' : endpoint;
const chartEndpoint = [ 'variations', 'categories' ].includes( endpoint )
? 'products'
: endpoint;
const primaryData = getSummary
? getReportChartData( chartEndpoint, 'primary', query, select )
: {};

View File

@ -13,16 +13,22 @@ export const charts = [
{
key: 'items_sold',
label: __( 'Items Sold', 'wc-admin' ),
order: 'desc',
orderby: 'items_sold',
type: 'number',
},
{
key: 'net_revenue',
label: __( 'Net Revenue', 'wc-admin' ),
order: 'desc',
orderby: 'net_revenue',
type: 'currency',
},
{
key: 'orders_count',
label: __( 'Orders Count', 'wc-admin' ),
order: 'desc',
orderby: 'orders_count',
type: 'number',
},
];

View File

@ -4,6 +4,7 @@
*/
import { Component, Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
import { __ } from '@wordpress/i18n';
/**
* WooCommerce dependencies
@ -20,23 +21,53 @@ import ReportChart from 'analytics/components/report-chart';
import ReportSummary from 'analytics/components/report-summary';
export default class CategoriesReport extends Component {
getChartMeta() {
const { query } = this.props;
const isCategoryDetailsView =
'top_items' === query.filter ||
'top_revenue' === query.filter ||
'compare-categories' === query.filter;
const isSingleCategoryView = query.categories && 1 === query.categories.split( ',' ).length;
const mode =
isCategoryDetailsView || isSingleCategoryView ? 'item-comparison' : 'time-comparison';
const itemsLabel = __( '%d categories', 'wc-admin' );
return {
itemsLabel,
mode,
};
}
render() {
const { query, path } = this.props;
const { mode, itemsLabel } = this.getChartMeta();
const chartQuery = {
...query,
};
if ( 'item-comparison' === mode ) {
chartQuery.segmentby = 'category';
}
return (
<Fragment>
<ReportFilters query={ query } path={ path } filters={ filters } />
<ReportSummary
charts={ charts }
endpoint="categories"
query={ query }
endpoint="products"
query={ chartQuery }
selectedChart={ getSelectedChart( query.chart, charts ) }
/>
<ReportChart
filters={ filters }
charts={ charts }
endpoint="categories"
mode={ mode }
endpoint="products"
path={ path }
query={ query }
query={ chartQuery }
itemsLabel={ itemsLabel }
selectedChart={ getSelectedChart( query.chart, charts ) }
/>
<CategoriesReportTable query={ query } />
@ -47,4 +78,5 @@ export default class CategoriesReport extends Component {
CategoriesReport.propTypes = {
query: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
};

View File

@ -13,11 +13,15 @@ export const charts = [
{
key: 'orders_count',
label: __( 'Discounted Orders', 'wc-admin' ),
order: 'desc',
orderby: 'orders_count',
type: 'number',
},
{
key: 'amount',
label: __( 'Amount', 'wc-admin' ),
order: 'desc',
orderby: 'amount',
type: 'currency',
},
];

View File

@ -39,7 +39,7 @@ export const advancedFilters = {
remove: __( 'Remove customer name filter', 'wc-admin' ),
rule: __( 'Select a customer name filter match', 'wc-admin' ),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/cCsm3GeXJbE */
title: __( 'Name {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Name{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select customer name', 'wc-admin' ),
},
rules: [
@ -59,7 +59,7 @@ export const advancedFilters = {
type: 'customers',
getLabels: getRequestByIdString( NAMESPACE + '/customers', customer => ( {
id: customer.id,
label: [ customer.first_name, customer.last_name ].filter( Boolean ).join( ' ' ),
label: customer.name,
} ) ),
},
},
@ -70,7 +70,7 @@ export const advancedFilters = {
remove: __( 'Remove country filter', 'wc-admin' ),
rule: __( 'Select a country filter match', 'wc-admin' ),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/cCsm3GeXJbE */
title: __( 'Country {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Country{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select country', 'wc-admin' ),
},
rules: [
@ -111,7 +111,7 @@ export const advancedFilters = {
remove: __( 'Remove customer username filter', 'wc-admin' ),
rule: __( 'Select a customer username filter match', 'wc-admin' ),
/* translators: A sentence describing a customer username filter. See screen shot for context: https://cloudup.com/cCsm3GeXJbE */
title: __( 'Username {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Username{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select customer username', 'wc-admin' ),
},
rules: [
@ -139,7 +139,7 @@ export const advancedFilters = {
remove: __( 'Remove customer email filter', 'wc-admin' ),
rule: __( 'Select a customer email filter match', 'wc-admin' ),
/* translators: A sentence describing a customer email filter. See screen shot for context: https://cloudup.com/cCsm3GeXJbE */
title: __( 'Email {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Email{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select customer email', 'wc-admin' ),
},
rules: [
@ -168,7 +168,7 @@ export const advancedFilters = {
add: __( 'No. of Orders', 'wc-admin' ),
remove: __( 'Remove order filter', 'wc-admin' ),
rule: __( 'Select an order count filter match', 'wc-admin' ),
title: __( 'No. of Orders {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}No. of Orders{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
},
rules: [
{
@ -196,7 +196,7 @@ export const advancedFilters = {
add: __( 'Total Spend', 'wc-admin' ),
remove: __( 'Remove total spend filter', 'wc-admin' ),
rule: __( 'Select a total spend filter match', 'wc-admin' ),
title: __( 'Total Spend {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Total Spend{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
},
rules: [
{
@ -224,7 +224,7 @@ export const advancedFilters = {
add: __( 'AOV', 'wc-admin' ),
remove: __( 'Remove average older value filter', 'wc-admin' ),
rule: __( 'Select an average order value filter match', 'wc-admin' ),
title: __( 'AOV {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}AOV{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
},
rules: [
{
@ -254,7 +254,7 @@ export const advancedFilters = {
remove: __( 'Remove registered filter', 'wc-admin' ),
rule: __( 'Select a registered filter match', 'wc-admin' ),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/cCsm3GeXJbE */
title: __( 'Registered {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Registered{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select registered date', 'wc-admin' ),
},
rules: [
@ -284,7 +284,7 @@ export const advancedFilters = {
remove: __( 'Remove last active filter', 'wc-admin' ),
rule: __( 'Select a last active filter match', 'wc-admin' ),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/cCsm3GeXJbE */
title: __( 'Last active {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Last active{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select registered date', 'wc-admin' ),
},
rules: [

View File

@ -45,7 +45,7 @@ export const advancedFilters = {
remove: __( 'Remove product filter', 'wc-admin' ),
rule: __( 'Select a product filter match', 'wc-admin' ),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
title: __( 'Product {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Product{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select product', 'wc-admin' ),
},
rules: [
@ -73,7 +73,7 @@ export const advancedFilters = {
remove: __( 'Remove customer username filter', 'wc-admin' ),
rule: __( 'Select a customer username filter match', 'wc-admin' ),
/* translators: A sentence describing a customer username filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
title: __( 'Username {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Username{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select customer username', 'wc-admin' ),
},
rules: [
@ -101,7 +101,7 @@ export const advancedFilters = {
remove: __( 'Remove order number filter', 'wc-admin' ),
rule: __( 'Select a order number filter match', 'wc-admin' ),
/* translators: A sentence describing a order number filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
title: __( 'Order number {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Order number{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select order number', 'wc-admin' ),
},
rules: [
@ -135,7 +135,7 @@ export const advancedFilters = {
remove: __( 'Remove IP address filter', 'wc-admin' ),
rule: __( 'Select an IP address filter match', 'wc-admin' ),
/* translators: A sentence describing a order number filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
title: __( 'IP Address {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}IP Address{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select IP address', 'wc-admin' ),
},
rules: [

View File

@ -35,7 +35,7 @@ import withSelect from 'wc-api/with-select';
const REPORTS_FILTER = 'woocommerce-reports-list';
const getReports = () => {
const reports = applyFilters( REPORTS_FILTER, [
const reports = [
{
report: 'revenue',
title: __( 'Revenue', 'wc-admin' ),
@ -86,9 +86,9 @@ const getReports = () => {
title: __( 'Downloads', 'wc-admin' ),
component: DownloadsReport,
},
] );
];
return reports;
return applyFilters( REPORTS_FILTER, reports );
};
class Report extends Component {

View File

@ -20,6 +20,8 @@ export const charts = [
{
key: 'net_revenue',
label: __( 'Net Revenue', 'wc-admin' ),
order: 'desc',
orderby: 'net_total',
type: 'currency',
},
{
@ -30,6 +32,8 @@ export const charts = [
{
key: 'avg_items_per_order',
label: __( 'Average Items Per Order', 'wc-admin' ),
order: 'desc',
orderby: 'num_items_sold',
type: 'average',
},
];
@ -61,7 +65,7 @@ export const advancedFilters = {
remove: __( 'Remove order status filter', 'wc-admin' ),
rule: __( 'Select an order status filter match', 'wc-admin' ),
/* translators: A sentence describing an Order Status filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __( 'Order Status {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Order Status{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select an order status', 'wc-admin' ),
},
rules: [
@ -91,7 +95,7 @@ export const advancedFilters = {
remove: __( 'Remove products filter', 'wc-admin' ),
rule: __( 'Select a product filter match', 'wc-admin' ),
/* translators: A sentence describing a Product filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __( 'Product {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Product{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select products', 'wc-admin' ),
},
rules: [
@ -119,7 +123,7 @@ export const advancedFilters = {
remove: __( 'Remove coupon filter', 'wc-admin' ),
rule: __( 'Select a coupon filter match', 'wc-admin' ),
/* translators: A sentence describing a Coupon filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __( 'Coupon Code {{rule /}} {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Coupon Code{{/title}} {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select coupon codes', 'wc-admin' ),
},
rules: [
@ -146,7 +150,7 @@ export const advancedFilters = {
remove: __( 'Remove customer filter', 'wc-admin' ),
rule: __( 'Select a customer filter match', 'wc-admin' ),
/* translators: A sentence describing a Customer filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
title: __( 'Customer is {{filter /}}', 'wc-admin' ),
title: __( '{{title}}Customer is{{/title}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select a customer type', 'wc-admin' ),
},
input: {

View File

@ -45,7 +45,6 @@ export default class OrdersReportTable extends Component {
screenReaderLabel: __( 'Order ID', 'wc-admin' ),
key: 'id',
required: true,
isSortable: true,
},
{
label: __( 'Status', 'wc-admin' ),
@ -68,9 +67,9 @@ export default class OrdersReportTable extends Component {
},
{
label: __( 'Items Sold', 'wc-admin' ),
key: 'items_sold',
key: 'num_items_sold',
required: false,
isSortable: false,
isSortable: true,
isNumeric: true,
},
{
@ -83,9 +82,9 @@ export default class OrdersReportTable extends Component {
{
label: __( 'N. Revenue', 'wc-admin' ),
screenReaderLabel: __( 'Net Revenue', 'wc-admin' ),
key: 'net_revenue',
key: 'net_total',
required: true,
isSortable: false,
isSortable: true,
isNumeric: true,
},
];

View File

@ -13,16 +13,22 @@ export const charts = [
{
key: 'items_sold',
label: __( 'Items Sold', 'wc-admin' ),
order: 'desc',
orderby: 'items_sold',
type: 'number',
},
{
key: 'net_revenue',
label: __( 'Net Revenue', 'wc-admin' ),
order: 'desc',
orderby: 'net_revenue',
type: 'currency',
},
{
key: 'orders_count',
label: __( 'Orders Count', 'wc-admin' ),
order: 'desc',
orderby: 'orders_count',
type: 'number',
},
];

View File

@ -41,8 +41,8 @@ class ProductsReport extends Component {
isSingleProductView && isSingleProductVariable ? 'variations' : 'products';
const label =
isSingleProductView && isSingleProductVariable
? __( '%s variations', 'wc-admin' )
: __( '%s products', 'wc-admin' );
? __( '%d variations', 'wc-admin' )
: __( '%d products', 'wc-admin' );
return {
compareObject,

View File

@ -8,31 +8,41 @@ export const charts = [
{
key: 'gross_revenue',
label: __( 'Gross Revenue', 'wc-admin' ),
order: 'desc',
orderby: 'gross_revenue',
type: 'currency',
},
{
key: 'refunds',
label: __( 'Refunds', 'wc-admin' ),
order: 'desc',
orderby: 'refunds',
type: 'currency',
},
{
key: 'coupons',
label: __( 'Coupons', 'wc-admin' ),
order: 'desc',
orderby: 'coupons',
type: 'currency',
},
{
key: 'taxes',
label: __( 'Taxes', 'wc-admin' ),
order: 'desc',
orderby: 'taxes',
type: 'currency',
},
{
key: 'shipping',
label: __( 'Shipping', 'wc-admin' ),
orderby: 'shipping',
type: 'currency',
},
{
key: 'net_revenue',
label: __( 'Net Revenue', 'wc-admin' ),
orderby: 'net_revenue',
type: 'currency',
},
];

View File

@ -240,8 +240,8 @@ export default compose(
return {
tableData: {
items: {
data: get( revenueData, [ 'data', 'intervals' ] ),
totalResults: get( revenueData, [ 'totalResults' ] ),
data: get( revenueData, [ 'data', 'intervals' ], [] ),
totalResults: get( revenueData, [ 'totalResults' ], 0 ),
},
isError,
isRequesting,

View File

@ -17,6 +17,7 @@ export const filters = [
{ label: __( 'Out of Stock', 'wc-admin' ), value: 'outofstock' },
{ label: __( 'Low Stock', 'wc-admin' ), value: 'lowstock' },
{ label: __( 'In Stock', 'wc-admin' ), value: 'instock' },
{ label: __( 'On Backorder', 'wc-admin' ), value: 'onbackorder' },
],
},
];

View File

@ -76,7 +76,7 @@ export default class StockReportTable extends Component {
);
const stockStatusLink = (
<Link href={ 'post.php?action=edit&post=' + parent_id || id } type="wp-admin">
<Link href={ 'post.php?action=edit&post=' + ( parent_id || id ) } type="wp-admin">
{ stockStatuses[ stock_status ] }
</Link>
);
@ -103,23 +103,27 @@ export default class StockReportTable extends Component {
}
getSummary( totals ) {
const { products = 0, out_of_stock = 0, low_stock = 0, in_stock = 0 } = totals;
const { products = 0, outofstock = 0, lowstock = 0, instock = 0, onbackorder = 0 } = totals;
return [
{
label: _n( 'product', 'products', products, 'wc-admin' ),
value: numberFormat( products ),
},
{
label: __( 'out of stock', out_of_stock, 'wc-admin' ),
value: numberFormat( out_of_stock ),
label: __( 'out of stock', outofstock, 'wc-admin' ),
value: numberFormat( outofstock ),
},
{
label: __( 'low stock', low_stock, 'wc-admin' ),
value: numberFormat( low_stock ),
label: __( 'low stock', lowstock, 'wc-admin' ),
value: numberFormat( lowstock ),
},
{
label: __( 'in stock', in_stock, 'wc-admin' ),
value: numberFormat( in_stock ),
label: __( 'on backorder', onbackorder, 'wc-admin' ),
value: numberFormat( onbackorder ),
},
{
label: __( 'in stock', instock, 'wc-admin' ),
value: numberFormat( instock ),
},
];
}
@ -132,7 +136,7 @@ export default class StockReportTable extends Component {
endpoint="stock"
getHeadersContent={ this.getHeadersContent }
getRowsContent={ this.getRowsContent }
// getSummary={ this.getSummary }
getSummary={ this.getSummary }
query={ query }
tableQuery={ {
orderby: query.orderby || 'stock_status',

View File

@ -15,21 +15,29 @@ export const charts = [
{
key: 'total_tax',
label: __( 'Total Tax', 'wc-admin' ),
order: 'desc',
orderby: 'total_tax',
type: 'currency',
},
{
key: 'order_tax',
label: __( 'Order Tax', 'wc-admin' ),
order: 'desc',
orderby: 'order_tax',
type: 'currency',
},
{
key: 'shipping_tax',
label: __( 'Shipping Tax', 'wc-admin' ),
order: 'desc',
orderby: 'shipping_tax',
type: 'currency',
},
{
key: 'orders_count',
label: __( 'Orders Count', 'wc-admin' ),
order: 'desc',
orderby: 'orders_count',
type: 'number',
},
];

View File

@ -21,6 +21,7 @@ import './index.scss';
import { analyticsSettings } from './config';
import Header from 'header';
import Setting from './setting';
import withSelect from 'wc-api/with-select';
const SETTINGS_FILTER = 'woocommerce_admin_analytics_settings';
@ -33,6 +34,7 @@ class Settings extends Component {
this.state = {
settings: settings,
saving: false,
};
this.handleInputChange = this.handleInputChange.bind( this );
@ -59,9 +61,31 @@ class Settings extends Component {
}
};
componentDidUpdate() {
const { addNotice, isError, isRequesting } = this.props;
const { saving } = this.state;
if ( saving && ! isRequesting ) {
if ( ! isError ) {
addNotice( {
status: 'success',
message: __( 'Your settings have been successfully saved.', 'wc-admin' ),
} );
} else {
addNotice( {
status: 'error',
message: __( 'There was an error saving your settings. Please try again.', 'wc-admin' ),
} );
}
/* eslint-disable react/no-did-update-set-state */
this.setState( { saving: false } );
/* eslint-enable react/no-did-update-set-state */
}
}
saveChanges = () => {
this.props.updateSettings( this.state.settings );
// @todo Need a confirmation on successful update.
this.setState( { saving: true } );
};
handleInputChange( e ) {
@ -124,10 +148,21 @@ class Settings extends Component {
}
export default compose(
withSelect( select => {
const { getSettings, getSettingsError, isGetSettingsRequesting } = select( 'wc-api' );
const settings = getSettings();
const isError = Boolean( getSettingsError() );
const isRequesting = isGetSettingsRequesting();
return { getSettings, isError, isRequesting, settings };
} ),
withDispatch( dispatch => {
const { addNotice } = dispatch( 'wc-admin' );
const { updateSettings } = dispatch( 'wc-api' );
return {
addNotice,
updateSettings,
};
} )

View File

@ -10,6 +10,7 @@ import { Provider as SlotFillProvider } from 'react-slot-fill';
*/
import './stylesheets/_embedded.scss';
import { EmbedLayout } from './layout';
import 'store';
import 'wc-api/wp-data-store';
render(

View File

@ -12,7 +12,7 @@ import PropTypes from 'prop-types';
/**
* WooCommerce dependencies
*/
import { getNewPath, getPersistedQuery } from '@woocommerce/navigation';
import { getNewPath } from '@woocommerce/navigation';
import { Link } from '@woocommerce/components';
/**
@ -89,7 +89,7 @@ class Header extends Component {
{ _sections.map( ( section, i ) => {
const sectionPiece = Array.isArray( section ) ? (
<Link
href={ getNewPath( getPersistedQuery(), section[ 0 ], {} ) }
href={ getNewPath( {}, section[ 0 ], {} ) }
type={ isEmbedded ? 'wp-admin' : 'wc-admin' }
>
{ section[ 1 ] }

View File

@ -10,6 +10,7 @@ import { Provider as SlotFillProvider } from 'react-slot-fill';
*/
import './stylesheets/_index.scss';
import { PageLayout } from './layout';
import 'store';
import 'wc-api/wp-data-store';
render(

View File

@ -21,44 +21,52 @@ import Dashboard from 'dashboard';
import DevDocs from 'devdocs';
const getPages = () => {
const pages = [
{
container: Dashboard,
path: '/',
wpOpenMenu: 'toplevel_page_woocommerce',
wpClosedMenu: 'toplevel_page_wc-admin--analytics-revenue',
},
{
container: Analytics,
path: '/analytics',
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
},
{
container: AnalyticsSettings,
path: '/analytics/settings',
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
},
{
container: AnalyticsReport,
path: '/analytics/:report',
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
},
{
const pages = [];
if ( window.wcAdminFeatures.devdocs ) {
pages.push( {
container: DevDocs,
path: '/devdocs',
wpOpenMenu: 'toplevel_page_woocommerce',
wpClosedMenu: 'toplevel_page_wc-admin--analytics-revenue',
},
{
} );
pages.push( {
container: DevDocs,
path: '/devdocs/:component',
wpOpenMenu: 'toplevel_page_woocommerce',
wpClosedMenu: 'toplevel_page_wc-admin--analytics-revenue',
},
];
} );
}
if ( window.wcAdminFeatures.dashboard ) {
pages.push( {
container: Dashboard,
path: '/',
wpOpenMenu: 'toplevel_page_woocommerce',
wpClosedMenu: 'toplevel_page_wc-admin--analytics-revenue',
} );
}
if ( window.wcAdminFeatures.analytics ) {
pages.push( {
container: Analytics,
path: '/analytics',
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
} );
pages.push( {
container: AnalyticsSettings,
path: '/analytics/settings',
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
} );
pages.push( {
container: AnalyticsReport,
path: '/analytics/:report',
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
} );
}
return pages;
};

View File

@ -21,6 +21,7 @@ import { Controller, getPages } from './controller';
import Header from 'header';
import Notices from './notices';
import { recordPageView } from 'lib/tracks';
import TransientNotices from './transient-notices';
class Layout extends Component {
componentDidMount() {
@ -61,7 +62,8 @@ class Layout extends Component {
const { isEmbeded, ...restProps } = this.props;
return (
<div className="woocommerce-layout">
<Slot name="header" />
{ window.wcAdminFeatures[ 'activity-panels' ] && <Slot name="header" /> }
<TransientNotices />
<div className="woocommerce-layout__primary" id="woocommerce-layout__primary">
<Notices />

View File

@ -17,6 +17,10 @@
}
}
.woocommerce-feature-disabled-activity-panels .woocommerce-layout__primary {
margin-top: 20px;
}
.woocommerce-layout .woocommerce-layout__main {
padding-right: $fallback-gutter-large;
padding-right: $gutter-large;

View File

@ -0,0 +1,48 @@
/** @format */
/**
* External dependencies
*/
import classnames from 'classnames';
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import './style.scss';
import TransientNotice from './transient-notice';
import withSelect from 'wc-api/with-select';
class TransientNotices extends Component {
render() {
const { className, notices } = this.props;
const classes = classnames( 'woocommerce-transient-notices', className );
return (
<section className={ classes }>
{ notices && notices.map( ( notice, i ) => <TransientNotice key={ i } { ...notice } /> ) }
</section>
);
}
}
TransientNotices.propTypes = {
/**
* Additional class name to style the component.
*/
className: PropTypes.string,
/**
* Array of notices to be displayed.
*/
notices: PropTypes.array,
};
export default compose(
withSelect( select => {
const { getNotices } = select( 'wc-admin' );
const notices = getNotices();
return { notices };
} )
)( TransientNotices );

View File

@ -0,0 +1,38 @@
/** @format */
.woocommerce-transient-notices {
position: fixed;
bottom: $gap-small;
left: 0;
z-index: 99999;
}
.woocommerce-transient-notice {
transform: translateX(calc(-100% - #{$gap}));
transition: all 300ms cubic-bezier(0.42, 0, 0.58, 1);
max-height: 300px; // Used to animate sliding down when multiple notices exist on exit.
@media screen and (prefers-reduced-motion: reduce) {
transition: none;
}
&.slide-enter-active,
&.slide-enter-done {
transform: translateX(0%);
}
&.slide-exit-active {
transform: translateX(calc(-100% - #{$gap}));
}
&.slide-exit-done {
max-height: 0;
margin: 0;
padding: 0;
visibility: hidden;
}
.components-notice {
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
}

View File

@ -0,0 +1,104 @@
/** @format */
/**
* External dependencies
*/
import classnames from 'classnames';
import { Component } from '@wordpress/element';
import { CSSTransition } from 'react-transition-group';
import { noop } from 'lodash';
import { Notice } from '@wordpress/components';
import PropTypes from 'prop-types';
import { speak } from '@wordpress/a11y';
class TransientNotice extends Component {
constructor( props ) {
super( props );
this.state = {
visible: false,
timeout: null,
};
}
componentDidMount() {
const exitTime = this.props.exitTime;
const timeout = setTimeout(
() => {
this.setState( { visible: false } );
},
exitTime,
name,
exitTime
);
/* eslint-disable react/no-did-mount-set-state */
this.setState( { visible: true, timeout } );
/* eslint-enable react/no-did-mount-set-state */
speak( this.props.message );
}
componentWillUnmount() {
clearTimeout( this.state.timeout );
}
render() {
const { actions, className, isDismissible, message, onRemove, status } = this.props;
const classes = classnames( 'woocommerce-transient-notice', className );
return (
<CSSTransition in={ this.state.visible } timeout={ 300 } classNames="slide">
<div className={ classes }>
<Notice
status={ status }
isDismissible={ isDismissible }
onRemove={ onRemove }
actions={ actions }
>
{ message }
</Notice>
</div>
</CSSTransition>
);
}
}
TransientNotice.propTypes = {
/**
* Array of action objects.
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
*/
actions: PropTypes.array,
/**
* Additional class name to style the component.
*/
className: PropTypes.string,
/**
* Determines if the notice dimiss button should be shown.
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
*/
isDismissible: PropTypes.bool,
/**
* Function called when dismissing the notice.
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
*/
onRemove: PropTypes.func,
/**
* Type of notice to display.
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
*/
status: PropTypes.oneOf( [ 'success', 'error', 'warning' ] ),
/**
* Time in milliseconds until exit.
*/
exitTime: PropTypes.number,
};
TransientNotice.defaultProps = {
actions: [],
className: '',
exitTime: 7000,
isDismissible: false,
onRemove: noop,
status: 'warning',
};
export default TransientNotice;

View File

@ -0,0 +1,22 @@
/** @format */
/**
* External dependencies
*/
import { combineReducers, registerStore } from '@wordpress/data';
/**
* Internal dependencies
*/
import notices from './notices';
registerStore( 'wc-admin', {
reducer: combineReducers( {
...notices.reducers,
} ),
actions: {
...notices.actions,
},
selectors: {
...notices.selectors,
},
} );

View File

@ -0,0 +1,9 @@
/** @format */
const addNotice = notice => {
return { type: 'ADD_NOTICE', notice };
};
export default {
addNotice,
};

View File

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

View File

@ -0,0 +1,15 @@
/** @format */
const DEFAULT_STATE = [];
const notices = ( state = DEFAULT_STATE, action ) => {
if ( action.type === 'ADD_NOTICE' ) {
return [ ...state, action.notice ];
}
return state;
};
export default {
notices,
};

View File

@ -0,0 +1,9 @@
/** @format */
const getNotices = state => {
return state.notices;
};
export default {
getNotices,
};

View File

@ -0,0 +1,7 @@
/** @format */
export const DEFAULT_STATE = {
notices: [],
};
export const testNotice = { message: 'Test notice' };

View File

@ -0,0 +1,55 @@
/** @format */
/**
* Internal dependencies
*/
import actions from '../actions';
import { DEFAULT_STATE, testNotice } from './fixtures';
import reducers from '../reducers';
import selectors from '../selectors';
describe( 'actions', () => {
test( 'should create an add notice action', () => {
const expectedAction = {
type: 'ADD_NOTICE',
notice: testNotice,
};
expect( actions.addNotice( testNotice ) ).toEqual( expectedAction );
} );
} );
describe( 'selectors', () => {
const notices = [ testNotice ];
const updatedState = { ...DEFAULT_STATE, ...{ notices } };
test( 'should return an emtpy initial state', () => {
expect( selectors.getNotices( DEFAULT_STATE ) ).toEqual( [] );
} );
test( 'should have an array length matching number of notices', () => {
expect( selectors.getNotices( updatedState ).length ).toEqual( 1 );
} );
test( 'should return the message content', () => {
expect( selectors.getNotices( updatedState )[ 0 ].message ).toEqual( 'Test notice' );
} );
} );
describe( 'reducers', () => {
test( 'should return an emtpy initial state', () => {
expect( reducers.notices( DEFAULT_STATE.notices, {} ) ).toEqual( [] );
} );
test( 'should return the added notice', () => {
expect(
reducers.notices( DEFAULT_STATE.notices, { type: 'ADD_NOTICE', notice: testNotice } )
).toEqual( [ testNotice ] );
} );
const initialNotices = [ { message: 'Initial notice' } ];
test( 'should return the initial notice and the added notice', () => {
expect(
reducers.notices( initialNotices, { type: 'ADD_NOTICE', notice: testNotice } )
).toEqual( [ ...initialNotices, testNotice ] );
} );
} );

View File

@ -56,11 +56,13 @@
.components-button.is-button.is-primary {
color: $white;
}
.components-button.is-button.is-primary:hover,
.components-button.is-button.is-primary:active,
.components-button.is-button.is-primary:focus {
color: $white;
&:not(:disabled) {
&:hover,
&:active,
&:focus {
color: $white;
}
}
}
}

View File

@ -6,12 +6,9 @@ import { MINUTE } from '@fresh-data/framework';
export const NAMESPACE = '/wc/v4';
// @todo Remove once swagger endpoints are phased out.
export const SWAGGERNAMESPACE = 'https://virtserver.swaggerhub.com/peterfabian/wc-v3-api/1.0.0/';
export const DEFAULT_REQUIREMENT = {
timeout: 1 * MINUTE,
freshness: 5 * MINUTE,
freshness: 30 * MINUTE,
};
// WordPress & WooCommerce both set a hard limit of 100 for the per_page parameter

View File

@ -13,7 +13,7 @@ import { stringifyQuery } from '@woocommerce/navigation';
* Internal dependencies
*/
import { getResourceIdentifier, getResourcePrefix } from 'wc-api/utils';
import { NAMESPACE, SWAGGERNAMESPACE } from 'wc-api/constants';
import { NAMESPACE } from 'wc-api/constants';
const statEndpoints = [
'coupons',
@ -21,11 +21,10 @@ const statEndpoints = [
'orders',
'products',
'revenue',
'stock',
'taxes',
'customers',
];
// @todo Remove once swagger endpoints are phased out.
const swaggerEndpoints = [ 'categories' ];
const typeEndpointMap = {
'report-stats-query-orders': 'orders',
@ -34,6 +33,7 @@ const typeEndpointMap = {
'report-stats-query-categories': 'categories',
'report-stats-query-downloads': 'downloads',
'report-stats-query-coupons': 'coupons',
'report-stats-query-stock': 'stock',
'report-stats-query-taxes': 'taxes',
'report-stats-query-customers': 'customers',
};
@ -53,9 +53,7 @@ function read( resourceNames, fetch = apiFetch ) {
parse: false,
};
if ( swaggerEndpoints.indexOf( endpoint ) >= 0 ) {
fetchArgs.url = SWAGGERNAMESPACE + 'reports/' + endpoint + '/stats' + stringifyQuery( query );
} else if ( statEndpoints.indexOf( endpoint ) >= 0 ) {
if ( statEndpoints.indexOf( endpoint ) >= 0 ) {
fetchArgs.path = NAMESPACE + '/reports/' + endpoint + '/stats' + stringifyQuery( query );
} else {
fetchArgs.path = endpoint + stringifyQuery( query );

View File

@ -3,7 +3,7 @@
/**
* External dependencies
*/
import { find, forEach, isNull, get } from 'lodash';
import { find, forEach, isNull, get, includes } from 'lodash';
import moment from 'moment';
/**
@ -52,6 +52,9 @@ export function getFilterQuery( endpoint, query ) {
return {};
}
// Some stats endpoints don't have interval data, so they can ignore after/before params and omit that part of the response.
const noIntervalEndpoints = [ 'stock', 'customers' ];
/**
* Add timestamp to advanced filter parameters involving date. The api
* expects a timestamp for these values similar to `before` and `after`.
@ -136,10 +139,11 @@ export function getQueryFromConfig( config, advancedFilters, query ) {
/**
* Returns true if a report object is empty.
*
* @param {Object} report Report to check
* @param {Object} report Report to check
* @param {String} endpoint Endpoint slug
* @return {Boolean} True if report is data is empty.
*/
export function isReportDataEmpty( report ) {
export function isReportDataEmpty( report, endpoint ) {
if ( ! report ) {
return true;
}
@ -149,7 +153,9 @@ export function isReportDataEmpty( report ) {
if ( ! report.data.totals || isNull( report.data.totals ) ) {
return true;
}
if ( ! report.data.intervals || 0 === report.data.intervals.length ) {
const checkIntervals = ! includes( noIntervalEndpoints, endpoint );
if ( checkIntervals && ( ! report.data.intervals || 0 === report.data.intervals.length ) ) {
return true;
}
return false;
@ -168,15 +174,19 @@ function getRequestQuery( endpoint, dataType, query ) {
const interval = getIntervalForQuery( query );
const filterQuery = getFilterQuery( endpoint, query );
const end = datesFromQuery[ dataType ].before;
return {
order: 'asc',
interval,
per_page: MAX_PER_PAGE,
after: appendTimestamp( datesFromQuery[ dataType ].after, 'start' ),
before: appendTimestamp( end, 'end' ),
segmentby: query.segmentby,
...filterQuery,
};
const noIntervals = includes( noIntervalEndpoints, endpoint );
return noIntervals
? { ...filterQuery }
: {
order: 'asc',
interval,
per_page: MAX_PER_PAGE,
after: appendTimestamp( datesFromQuery[ dataType ].after, 'start' ),
before: appendTimestamp( end, 'end' ),
segmentby: query.segmentby,
...filterQuery,
};
}
/**
@ -250,7 +260,7 @@ export function getReportChartData( endpoint, dataType, query, select ) {
return { ...response, isRequesting: true };
} else if ( getReportStatsError( endpoint, requestQuery ) ) {
return { ...response, isError: true };
} else if ( isReportDataEmpty( stats ) ) {
} else if ( isReportDataEmpty( stats, endpoint ) ) {
return { ...response, isEmpty: true };
}

View File

@ -48,13 +48,13 @@ function updateSettings( resourceNames, data, fetch ) {
method: 'POST',
data: { value: settingsData[ setting ] },
} )
.then( settingsToSettingsResource )
.then( settingToSettingsResource.bind( null, data.settings ) )
.catch( error => {
return { [ resourceName ]: { error } };
} );
} );
return [ promises ];
return promises;
}
return [];
}
@ -65,6 +65,11 @@ function settingsToSettingsResource( settings ) {
return { [ 'settings' ]: { data: settingsData } };
}
function settingToSettingsResource( settings, setting ) {
settings[ setting.id ] = setting.value;
return { [ 'settings' ]: { data: settings } };
}
export default {
read,
update,

View File

@ -1,14 +1,34 @@
/** @format */
/**
* External dependencies
*/
import { isNil } from 'lodash';
/**
* Internal dependencies
*/
import { DEFAULT_REQUIREMENT } from '../constants';
const getSettings = ( getResource, requireResource ) => ( requirement = DEFAULT_REQUIREMENT ) => {
return requireResource( requirement, 'settings' ).data;
return requireResource( requirement, 'settings' ).data || {};
};
const getSettingsError = getResource => () => {
return getResource( 'settings' ).error;
};
const isGetSettingsRequesting = getResource => () => {
const { lastRequested, lastReceived } = getResource( 'settings' );
if ( isNil( lastRequested ) || isNil( lastReceived ) ) {
return true;
}
return lastRequested > lastReceived;
};
export default {
getSettings,
getSettingsError,
isGetSettingsRequesting,
};

View File

@ -0,0 +1,8 @@
{
"features": {
"activity-panels": false,
"analytics": false,
"dashboard": false,
"devdocs": false
}
}

View File

@ -0,0 +1,8 @@
{
"features": {
"activity-panels": true,
"analytics": true,
"dashboard": true,
"devdocs": true
}
}

View File

@ -0,0 +1,8 @@
{
"features": {
"activity-panels": false,
"analytics": true,
"dashboard": true,
"devdocs": false
}
}

View File

@ -1,5 +1,6 @@
* [Overview](/)
* [Components](components/)
* [Feature Flags](feature-flags)
* [Data](data)
* [Documentation](documentation)
* [Layout](layout)

View File

@ -38,9 +38,14 @@ Current path
### `primaryData`
- **Required**
- Type: Object
- Default: null
- Default: `{
data: {
intervals: [],
},
isError: false,
isRequesting: false,
}`
Primary data to display in the chart.
@ -54,9 +59,14 @@ The query string represented in object form.
### `secondaryData`
- **Required**
- Type: Object
- Default: null
- Default: `{
data: {
intervals: [],
},
isError: false,
isRequesting: false,
}`
Secondary data to display in the chart.

View File

@ -42,3 +42,17 @@ The query string represented in object form.
Properties of the selected chart.
### `summaryData`
- Type: Object
- Default: `{
totals: {
primary: {},
secondary: {},
},
isError: false,
isRequesting: false,
}`
Data to display in the SummaryNumbers.

View File

@ -68,9 +68,8 @@ The name of the property in the item object which contains the id.
### `primaryData`
- **Required**
- Type: Object
- Default: null
- Default: `{}`
Primary data of that report. If it's not provided, it will be automatically
loaded via the provided `endpoint`.
@ -78,7 +77,13 @@ loaded via the provided `endpoint`.
### `tableData`
- Type: Object
- Default: `{}`
- Default: `{
items: {
data: [],
totalResults: 0,
},
query: {},
}`
Table data of that report. If it's not provided, it will be automatically
loaded via the provided `endpoint`.

View File

@ -13,6 +13,14 @@ Props
Allowed intervals to show in a dropdown.
### `baseValue`
- Type: Number
- Default: `0`
Base chart value. If no data value is different than the baseValue, the
`emptyMessage` will be displayed if provided.
### `data`
- Type: Array
@ -27,6 +35,14 @@ An array of data.
Format to parse dates into d3 time format
### `emptyMessage`
- Type: String
- Default: null
The message to be displayed if there is no data to render. If no message is provided,
nothing will be displayed.
### `itemsLabel`
- Type: String

View File

@ -7,6 +7,13 @@ A search box which autocompletes results while typing, allowing for the user to
Props
-----
### `allowFreeTextSearch`
- Type: Boolean
- Default: `false`
Render additional options in the autocompleter to allow free text entering depending on the type.
### `className`
- Type: String
@ -54,6 +61,13 @@ search box.
Render tags inside input, otherwise render below input.
### `showClearButton`
- Type: Boolean
- Default: `false`
Render a 'Clear' button next to the input box to remove its contents.
### `staticResults`
- Type: Boolean

View File

@ -132,13 +132,6 @@ The total number of rows to display per page.
The string to use as a query parameter when searching row items.
### `searchParam`
- Type: String
- Default: null
Url query parameter search function operates on
### `showMenu`
- Type: Boolean

View File

@ -0,0 +1,47 @@
# Feature Flags
Features inside the `wc-admin` repository can be in various states of completeness. In addition to the development copy of `wc-admin`, feature plugin versions are bundled, and code is merged to WooCommerce core. To provide a way for improved control over how these features are released in these different environments, `wc-admin` has a system for feature flags.
We currently support the following environments:
| Environment | Description |
|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| development | Development - All features should be enabled in development. These flags are also used in both JS and PHP tests. Ran using `npm start`. |
| plugin | Plugin - A packaged release of the featured plugin, for GitHub WordPress.org. Ran using `npm run-script build:release`. | |
| core | Core - assets/files ready and stable enough for core merge. @todo No build process exists yet.
## Adding a new flag
Flags can be added to the files located in the `config/` directory. Make sure to add a flag for each environment and explicitly set the flag to false.
Please add new feature flags alphabetically so they are easy to find.
## Basic Use - Client
The `window.wcAdminFeatures` constant is a global variable containing the feature flags.
Instances of `window.wcAdminFeatures` are replaced during the webpack build process by using webpack's [define plugin](https://webpack.js.org/plugins/define-plugin/). Using `webpack` for this allows us to eliminate dead code when making minified/production builds (`plugin`, or `core` environments).
To check if a feature is enabled, you can simplify check the boolean value of a feature:
```
{ window.wcAdminFeatures[ 'activity-panels' ] && <ActivityPanel /> }
```
We also expose CSS classes on the `body` tag, so that you can target specific feature states:
```
<body class="wp-admin woocommerce-page woocommerce-feature-disabled-analytics woocommerce-feature-enabled-activity-panels ....">
```
## Basic Use - Server
Feature flags are also available via PHP. To ensure these are consistent with the built client assets, `includes/feature-flags.php` is generated by the plugin build process or `npm start`. Do not edit `includes/feature-flags.php` directly.
To check if a feature is enabled, you can use the `wc_admin_is_feature_enabled()`:
```
if ( wc_admin_is_feature_enabled( 'activity-panels' ) ) {
add_action( 'admin_header', 'wc_admin_activity_panel' );
}
```

View File

@ -13,46 +13,14 @@ defined( 'ABSPATH' ) || exit;
* Customers controller.
*
* @package WooCommerce Admin/API
* @extends WC_REST_Customers_Controller
* @extends WC_Admin_REST_Reports_Customers_Controller
*/
class WC_Admin_REST_Customers_Controller extends WC_REST_Customers_Controller {
// @todo Add support for guests here. See https://wp.me/p7bje6-1dM.
class WC_Admin_REST_Customers_Controller extends WC_Admin_REST_Reports_Customers_Controller {
/**
* Endpoint namespace.
* Route base.
*
* @var string
*/
protected $namespace = 'wc/v4';
/**
* Searches emails by partial search instead of a strict match.
* See "search parameters" under https://codex.wordpress.org/Class_Reference/WP_User_Query.
*
* @param array $prepared_args Prepared search filter args from the customer endpoint.
* @param array $request Request/query arguments.
* @return array
*/
public static function update_search_filters( $prepared_args, $request ) {
if ( ! empty( $request['email'] ) ) {
$prepared_args['search'] = '*' . $prepared_args['search'] . '*';
}
return $prepared_args;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
// Allow partial email matches. Previously, this was of format 'email' which required a strict "test@example.com" format.
// This, in combination with `update_search_filters` allows us to do partial searches.
$params['email']['format'] = '';
return $params;
}
protected $rest_base = 'customers';
}
add_filter( 'woocommerce_rest_customer_query', array( 'WC_Admin_REST_Customers_Controller', 'update_search_filters' ), 10, 2 );

View File

@ -46,7 +46,7 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
$args['order'] = $request['order'];
$args['orderby'] = $request['orderby'];
$args['match'] = $request['match'];
$args['name'] = $request['name'];
$args['search'] = $request['search'];
$args['username'] = $request['username'];
$args['email'] = $request['email'];
$args['country'] = $request['country'];
@ -60,6 +60,7 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
$args['avg_order_value_max'] = $request['avg_order_value_max'];
$args['last_order_before'] = $request['last_order_before'];
$args['last_order_after'] = $request['last_order_after'];
$args['customers'] = $request['customers'];
$between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized_params_numeric = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params_numeric, false );
@ -172,7 +173,7 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
'title' => 'report_customers',
'type' => 'object',
'properties' => array(
'customer_id' => array(
'id' => array(
'description' => __( 'Customer ID.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
@ -333,8 +334,8 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['name'] = array(
'description' => __( 'Limit response to objects with a specfic customer name.', 'wc-admin' ),
$params['search'] = array(
'description' => __( 'Limit response to objects with a customer name containing the search term.', 'wc-admin' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
@ -446,6 +447,17 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['customers'] = array(
'description' => __( 'Limit result to items with specified customer ids.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
return $params;
}
}

View File

@ -41,7 +41,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
$args['registered_before'] = $request['registered_before'];
$args['registered_after'] = $request['registered_after'];
$args['match'] = $request['match'];
$args['name'] = $request['name'];
$args['search'] = $request['search'];
$args['username'] = $request['username'];
$args['email'] = $request['email'];
$args['country'] = $request['country'];
@ -55,6 +55,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
$args['avg_order_value_max'] = $request['avg_order_value_max'];
$args['last_order_before'] = $request['last_order_before'];
$args['last_order_after'] = $request['last_order_after'];
$args['customers'] = $request['customers'];
$between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized_params_numeric = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params_numeric, false );
@ -77,8 +78,6 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
$report_data = $customers_query->get_data();
$out_data = array(
'totals' => $report_data,
// @todo Is this needed? the single element array tricks the isReportDataEmpty() selector.
'intervals' => array( (object) array() ),
);
return rest_ensure_response( $out_data );
@ -161,55 +160,6 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
'readonly' => true,
'properties' => $totals,
),
'intervals' => array( // @todo Remove this?
'description' => __( 'Reports data grouped by intervals.', 'wc-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'interval' => array(
'description' => __( 'Type of interval.', 'wc-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'date_start' => array(
'description' => __( "The date the report start, in the site's timezone.", 'wc-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_start_gmt' => array(
'description' => __( 'The date the report start, as GMT.', 'wc-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end' => array(
'description' => __( "The date the report end, in the site's timezone.", 'wc-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_end_gmt' => array(
'description' => __( 'The date the report end, as GMT.', 'wc-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'wc-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
),
),
),
),
);
@ -246,7 +196,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['name'] = array(
$params['search'] = array(
'description' => __( 'Limit response to objects with a specfic customer name.', 'wc-admin' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
@ -359,6 +309,16 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['customers'] = array(
'description' => __( 'Limit result to items with specified customer ids.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
return $params;
}

View File

@ -170,7 +170,7 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
),
'avg_items_per_order' => array(
'description' => __( 'Average items per order', 'wc-admin' ),
'type' => 'integer',
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),

View File

@ -0,0 +1,138 @@
<?php
/**
* REST API Reports stock stats controller
*
* Handles requests to the /reports/stock/stats endpoint.
*
* @package WooCommerce Admin/API
*/
defined( 'ABSPATH' ) || exit;
/**
* REST API Reports stock stats controller class.
*
* @package WooCommerce/API
* @extends WC_REST_Reports_Controller
*/
class WC_Admin_REST_Reports_Stock_Stats_Controller extends WC_REST_Reports_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v4';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/stock/stats';
/**
* Get Stock Status Totals.
*
* @param WP_REST_Request $request Request data.
* @return array|WP_Error
*/
public function get_items( $request ) {
$stock_query = new WC_Admin_Reports_Stock_Stats_Query();
$report_data = $stock_query->get_data();
$out_data = array(
'totals' => $report_data,
);
return rest_ensure_response( $out_data );
}
/**
* Prepare a report object for serialization.
*
* @param WC_Product $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param WC_Product $product The original bject.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_stock_stats', $response, $product, $request );
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$totals = array(
'products' => array(
'description' => __( 'Number of products.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'lowstock' => array(
'description' => __( 'Number of low stock products.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
$status_options = wc_get_product_stock_status_options();
foreach ( $status_options as $status => $label ) {
$totals[ $status ] = array(
/* translators: Stock status. Example: "Number of low stock products */
'description' => sprintf( __( 'Number of %s products.', 'wc-admin' ), $label ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
);
}
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_customers_stats',
'type' => 'object',
'properties' => array(
'totals' => array(
'description' => __( 'Totals data.', 'wc-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
return $params;
}
}

View File

@ -12,43 +12,6 @@ defined( 'ABSPATH' ) || exit;
*/
class WC_Admin_Api_Init {
/**
* Action hook for reducing a range of batches down to single actions.
*/
const QUEUE_BATCH_ACTION = 'wc-admin_queue_batches';
/**
* Action hook for queuing an action after another is complete.
*/
const QUEUE_DEPEDENT_ACTION = 'wc-admin_queue_dependent_action';
/**
* Action hook for processing a batch of customers.
*/
const CUSTOMERS_BATCH_ACTION = 'wc-admin_process_customers_batch';
/**
* Action hook for processing a batch of orders.
*/
const ORDERS_BATCH_ACTION = 'wc-admin_process_orders_batch';
/**
* Action hook for initializing the orders lookup batch creation.
*/
const ORDERS_LOOKUP_BATCH_INIT = 'wc-admin_orders_lookup_batch_init';
/**
* Action hook for processing a batch of orders.
*/
const SINGLE_ORDER_ACTION = 'wc-admin_process_order';
/**
* Queue instance.
*
* @var WC_Queue_Interface
*/
protected static $queue = null;
/**
* Boostrap REST API.
*/
@ -57,50 +20,14 @@ class WC_Admin_Api_Init {
add_action( 'plugins_loaded', array( $this, 'init_classes' ), 19 );
// Hook in data stores.
add_filter( 'woocommerce_data_stores', array( 'WC_Admin_Api_Init', 'add_data_stores' ) );
// Add wc-admin report tables to list of WooCommerce tables.
add_filter( 'woocommerce_install_get_tables', array( 'WC_Admin_Api_Init', 'add_tables' ) );
// REST API extensions init.
add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
add_filter( 'rest_endpoints', array( 'WC_Admin_Api_Init', 'filter_rest_endpoints' ), 10, 1 );
add_filter( 'woocommerce_debug_tools', array( 'WC_Admin_Api_Init', 'add_regenerate_tool' ) );
// Initialize syncing hooks.
add_action( 'wp_loaded', array( __CLASS__, 'orders_lookup_update_init' ) );
// Initialize scheduled action handlers.
add_action( self::QUEUE_BATCH_ACTION, array( __CLASS__, 'queue_batches' ), 10, 3 );
add_action( self::QUEUE_DEPEDENT_ACTION, array( __CLASS__, 'queue_dependent_action' ), 10, 3 );
add_action( self::CUSTOMERS_BATCH_ACTION, array( __CLASS__, 'customer_lookup_process_batch' ) );
add_action( self::ORDERS_BATCH_ACTION, array( __CLASS__, 'orders_lookup_process_batch' ) );
add_action( self::ORDERS_LOOKUP_BATCH_INIT, array( __CLASS__, 'orders_lookup_batch_init' ) );
add_action( self::SINGLE_ORDER_ACTION, array( __CLASS__, 'orders_lookup_process_order' ) );
// Add currency symbol to orders endpoint response.
add_filter( 'woocommerce_rest_prepare_shop_order_object', array( __CLASS__, 'add_currency_symbol_to_order_response' ) );
}
/**
* Get queue instance.
*
* @return WC_Queue_Interface
*/
public static function queue() {
if ( is_null( self::$queue ) ) {
self::$queue = WC()->queue();
}
return self::$queue;
}
/**
* Set queue instance.
*
* @param WC_Queue_Interface $queue Queue instance.
*/
public static function set_queue( $queue ) {
self::$queue = $queue;
}
/**
* Init classes.
*/
@ -141,6 +68,7 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-downloads-stats-query.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-customers-query.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-customers-stats-query.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-stock-stats-query.php';
// Data stores.
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-data-store.php';
@ -158,6 +86,7 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-downloads-stats-data-store.php';
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-customers-data-store.php';
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-customers-stats-data-store.php';
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-stock-stats-data-store.php';
// Data triggers.
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-notes-data-store.php';
@ -173,7 +102,6 @@ class WC_Admin_Api_Init {
public function rest_api_init() {
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-admin-notes-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-coupons-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-customers-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-data-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-data-countries-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-data-download-ips-controller.php';
@ -204,7 +132,9 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-taxes-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-taxes-stats-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-stock-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-stock-stats-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-taxes-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-customers-controller.php';
$controllers = apply_filters(
'woocommerce_admin_rest_controllers',
@ -236,6 +166,7 @@ class WC_Admin_Api_Init {
'WC_Admin_REST_Reports_Coupons_Controller',
'WC_Admin_REST_Reports_Coupons_Stats_Controller',
'WC_Admin_REST_Reports_Stock_Controller',
'WC_Admin_REST_Reports_Stock_Stats_Controller',
'WC_Admin_REST_Reports_Downloads_Controller',
'WC_Admin_REST_Reports_Downloads_Stats_Controller',
'WC_Admin_REST_Reports_Customers_Controller',
@ -429,323 +360,6 @@ class WC_Admin_Api_Init {
return $endpoints;
}
/**
* Regenerate data for reports.
*/
public static function regenerate_report_data() {
// Add registered customers to the lookup table before updating order stats
// so that the orders can be associated with the `customer_id` column.
self::customer_lookup_batch_init();
// Queue orders lookup to occur after customers lookup generation is done.
self::queue_dependent_action( self::ORDERS_LOOKUP_BATCH_INIT, array(), self::CUSTOMERS_BATCH_ACTION );
}
/**
* Adds regenerate tool.
*
* @param array $tools List of tools.
* @return array
*/
public static function add_regenerate_tool( $tools ) {
return array_merge(
$tools,
array(
'rebuild_stats' => array(
'name' => __( 'Rebuild reports data', 'wc-admin' ),
'button' => __( 'Rebuild reports', 'wc-admin' ),
'desc' => __( 'This tool will rebuild all of the information used by the reports.', 'wc-admin' ),
'callback' => array( 'WC_Admin_Api_Init', 'regenerate_report_data' ),
),
)
);
}
/**
* Schedule an action to process a single Order.
*
* @param int $order_id Order ID.
* @return void
*/
public static function schedule_single_order_process( $order_id ) {
if ( 'shop_order' !== get_post_type( $order_id ) ) {
return;
}
// This can get called multiple times for a single order, so we look
// for existing pending jobs for the same order to avoid duplicating efforts.
$existing_jobs = self::queue()->search(
array(
'status' => 'pending',
'per_page' => 1,
'claimed' => false,
'search' => "[{$order_id}]",
)
);
if ( $existing_jobs ) {
$existing_job = current( $existing_jobs );
// Bail out if there's a pending single order action, or a pending dependent action.
if (
( self::SINGLE_ORDER_ACTION === $existing_job->get_hook() ) ||
(
self::QUEUE_DEPEDENT_ACTION === $existing_job->get_hook() &&
in_array( self::SINGLE_ORDER_ACTION, $existing_job->get_args() )
)
) {
return;
}
}
// We want to ensure that customer lookup updates are scheduled before order updates.
self::queue_dependent_action( self::SINGLE_ORDER_ACTION, array( $order_id ), self::CUSTOMERS_BATCH_ACTION );
}
/**
* Attach order lookup update hooks.
*/
public static function orders_lookup_update_init() {
// Activate WC_Order extension.
WC_Admin_Order::add_filters();
add_action( 'save_post_shop_order', array( __CLASS__, 'schedule_single_order_process' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'schedule_single_order_process' ) );
WC_Admin_Reports_Orders_Stats_Data_Store::init();
WC_Admin_Reports_Customers_Data_Store::init();
WC_Admin_Reports_Coupons_Data_Store::init();
WC_Admin_Reports_Products_Data_Store::init();
WC_Admin_Reports_Taxes_Data_Store::init();
}
/**
* Init order/product lookup tables update (in batches).
*/
public static function orders_lookup_batch_init() {
$batch_size = self::get_batch_size( self::ORDERS_BATCH_ACTION );
$order_query = new WC_Order_Query(
array(
'return' => 'ids',
'limit' => 1,
'paginate' => true,
)
);
$result = $order_query->get_orders();
if ( 0 === $result->total ) {
return;
}
$num_batches = ceil( $result->total / $batch_size );
self::queue_batches( 1, $num_batches, self::ORDERS_BATCH_ACTION );
}
/**
* Process a batch of orders to update (stats and products).
*
* @param int $batch_number Batch number to process (essentially a query page number).
* @return void
*/
public static function orders_lookup_process_batch( $batch_number ) {
$batch_size = self::get_batch_size( self::ORDERS_BATCH_ACTION );
$order_query = new WC_Order_Query(
array(
'return' => 'ids',
'limit' => $batch_size,
'page' => $batch_number,
'orderby' => 'ID',
'order' => 'ASC',
)
);
$order_ids = $order_query->get_orders();
foreach ( $order_ids as $order_id ) {
self::orders_lookup_process_order( $order_id );
}
}
/**
* Process a single order to update lookup tables for.
* If an error is encountered in one of the updates, a retry action is scheduled.
*
* @param int $order_id Order ID.
* @return void
*/
public static function orders_lookup_process_order( $order_id ) {
$result = array_sum(
array(
WC_Admin_Reports_Orders_Stats_Data_Store::sync_order( $order_id ),
WC_Admin_Reports_Products_Data_Store::sync_order_products( $order_id ),
WC_Admin_Reports_Coupons_Data_Store::sync_order_coupons( $order_id ),
WC_Admin_Reports_Taxes_Data_Store::sync_order_taxes( $order_id ),
)
);
// If all updates were either skipped or successful, we're done.
// The update methods return -1 for skip, or a boolean success indicator.
if ( 4 === absint( $result ) ) {
return;
}
// Otherwise assume an error occurred and reschedule.
self::schedule_single_order_process( $order_id );
}
/**
* Returns the batch size for regenerating reports.
* Note: can differ per batch action.
*
* @param string $action Single batch action name.
* @return int Batch size.
*/
public static function get_batch_size( $action ) {
$batch_sizes = array(
self::QUEUE_BATCH_ACTION => 100,
self::CUSTOMERS_BATCH_ACTION => 25,
self::ORDERS_BATCH_ACTION => 10,
);
$batch_size = isset( $batch_sizes[ $action ] ) ? $batch_sizes[ $action ] : 25;
/**
* Filter the batch size for regenerating a report table.
*
* @param int $batch_size Batch size.
* @param string $action Batch action name.
*/
return apply_filters( 'wc_admin_report_regenerate_batch_size', $batch_size, $action );
}
/**
* Queue a large number of batch jobs, respecting the batch size limit.
* Reduces a range of batches down to "single batch" jobs.
*
* @param int $range_start Starting batch number.
* @param int $range_end Ending batch number.
* @param string $single_batch_action Action to schedule for a single batch.
* @return void
*/
public static function queue_batches( $range_start, $range_end, $single_batch_action ) {
$batch_size = self::get_batch_size( self::QUEUE_BATCH_ACTION );
$range_size = 1 + ( $range_end - $range_start );
$action_timestamp = time() + 5;
if ( $range_size > $batch_size ) {
// If the current batch range is larger than a single batch,
// split the range into $queue_batch_size chunks.
$chunk_size = ceil( $range_size / $batch_size );
for ( $i = 0; $i < $batch_size; $i++ ) {
$batch_start = $range_start + ( $i * $chunk_size );
$batch_end = min( $range_end, $range_start + ( $chunk_size * ( $i + 1 ) ) - 1 );
self::queue()->schedule_single(
$action_timestamp,
self::QUEUE_BATCH_ACTION,
array( $batch_start, $batch_end, $single_batch_action )
);
}
} else {
// Otherwise, queue the single batches.
for ( $i = $range_start; $i <= $range_end; $i++ ) {
self::queue()->schedule_single( $action_timestamp, $single_batch_action, array( $i ) );
}
}
}
/**
* Queue an action to run after another.
*
* @param string $action Action to run after prerequisite.
* @param array $action_args Action arguments.
* @param string $prerequisite_action Prerequisite action.
*/
public static function queue_dependent_action( $action, $action_args, $prerequisite_action ) {
$blocking_jobs = self::queue()->search(
array(
'status' => 'pending',
'orderby' => 'date',
'order' => 'DESC',
'per_page' => 1,
'claimed' => false,
'search' => $prerequisite_action, // search is used instead of hook to find queued batch creation.
)
);
$next_job_schedule = null;
$blocking_job_hook = null;
if ( $blocking_jobs ) {
$blocking_job = current( $blocking_jobs );
$blocking_job_hook = $blocking_job->get_hook();
$next_job_schedule = $blocking_job->get_schedule()->next();
}
// Eliminate the false positive scenario where the blocking job is
// actually another queued dependent action awaiting the same prerequisite.
// Also, ensure that the next schedule is a DateTime (it can be null).
if (
is_a( $next_job_schedule, 'DateTime' ) &&
( self::QUEUE_DEPEDENT_ACTION !== $blocking_job_hook )
) {
self::queue()->schedule_single(
$next_job_schedule->getTimestamp() + 5,
self::QUEUE_DEPEDENT_ACTION,
array( $action, $action_args, $prerequisite_action )
);
} else {
self::queue()->schedule_single( time() + 5, $action, $action_args );
}
}
/**
* Init customer lookup table update (in batches).
*/
public static function customer_lookup_batch_init() {
$batch_size = self::get_batch_size( self::CUSTOMERS_BATCH_ACTION );
$customer_query = new WP_User_Query(
array(
'fields' => 'ID',
'number' => 1,
)
);
$total_customers = $customer_query->get_total();
if ( 0 === $total_customers ) {
return;
}
$num_batches = ceil( $total_customers / $batch_size );
self::queue_batches( 1, $num_batches, self::CUSTOMERS_BATCH_ACTION );
}
/**
* Process a batch of customers to update.
*
* @param int $batch_number Batch number to process (essentially a query page number).
* @return void
*/
public static function customer_lookup_process_batch( $batch_number ) {
$batch_size = self::get_batch_size( self::CUSTOMERS_BATCH_ACTION );
$customer_query = new WP_User_Query(
array(
'fields' => 'ID',
'orderby' => 'ID',
'order' => 'ASC',
'number' => $batch_size,
'paged' => $batch_number,
)
);
$customer_ids = $customer_query->get_results();
foreach ( $customer_ids as $customer_id ) {
// @todo Schedule single customer update if this fails?
WC_Admin_Reports_Customers_Data_Store::update_registered_customer( $customer_id );
}
}
/**
* Adds data stores.
*
@ -756,187 +370,27 @@ class WC_Admin_Api_Init {
return array_merge(
$data_stores,
array(
'report-revenue-stats' => 'WC_Admin_Reports_Orders_Stats_Data_Store',
'report-orders' => 'WC_Admin_Reports_Orders_Data_Store',
'report-orders-stats' => 'WC_Admin_Reports_Orders_Stats_Data_Store',
'report-products' => 'WC_Admin_Reports_Products_Data_Store',
'report-variations' => 'WC_Admin_Reports_Variations_Data_Store',
'report-products-stats' => 'WC_Admin_Reports_Products_Stats_Data_Store',
'report-categories' => 'WC_Admin_Reports_Categories_Data_Store',
'report-taxes' => 'WC_Admin_Reports_Taxes_Data_Store',
'report-taxes-stats' => 'WC_Admin_Reports_Taxes_Stats_Data_Store',
'report-coupons' => 'WC_Admin_Reports_Coupons_Data_Store',
'report-coupons-stats' => 'WC_Admin_Reports_Coupons_Stats_Data_Store',
'report-downloads' => 'WC_Admin_Reports_Downloads_Data_Store',
'report-revenue-stats' => 'WC_Admin_Reports_Orders_Stats_Data_Store',
'report-orders' => 'WC_Admin_Reports_Orders_Data_Store',
'report-orders-stats' => 'WC_Admin_Reports_Orders_Stats_Data_Store',
'report-products' => 'WC_Admin_Reports_Products_Data_Store',
'report-variations' => 'WC_Admin_Reports_Variations_Data_Store',
'report-products-stats' => 'WC_Admin_Reports_Products_Stats_Data_Store',
'report-categories' => 'WC_Admin_Reports_Categories_Data_Store',
'report-taxes' => 'WC_Admin_Reports_Taxes_Data_Store',
'report-taxes-stats' => 'WC_Admin_Reports_Taxes_Stats_Data_Store',
'report-coupons' => 'WC_Admin_Reports_Coupons_Data_Store',
'report-coupons-stats' => 'WC_Admin_Reports_Coupons_Stats_Data_Store',
'report-downloads' => 'WC_Admin_Reports_Downloads_Data_Store',
'report-downloads-stats' => 'WC_Admin_Reports_Downloads_Stats_Data_Store',
'admin-note' => 'WC_Admin_Notes_Data_Store',
'report-customers' => 'WC_Admin_Reports_Customers_Data_Store',
'report-customers-stats' => 'WC_Admin_Reports_Customers_Stats_Data_Store',
'report-stock-stats' => 'WC_Admin_Reports_Stock_Stats_Data_Store',
)
);
}
/**
* Adds new tables.
*
* @param array $wc_tables List of WooCommerce tables.
* @return array
*/
public static function add_tables( $wc_tables ) {
global $wpdb;
return array_merge(
$wc_tables,
array(
// @todo Will this work on multisite?
"{$wpdb->prefix}wc_order_stats",
"{$wpdb->prefix}wc_order_product_lookup",
"{$wpdb->prefix}wc_order_tax_lookup",
"{$wpdb->prefix}wc_order_coupon_lookup",
"{$wpdb->prefix}wc_admin_notes",
"{$wpdb->prefix}wc_admin_note_actions",
"{$wpdb->prefix}wc_customer_lookup",
)
);
}
/**
* Get database schema.
*
* @return string
*/
private static function get_schema() {
global $wpdb;
if ( $wpdb->has_cap( 'collation' ) ) {
$collate = $wpdb->get_charset_collate();
}
$tables = "
CREATE TABLE {$wpdb->prefix}wc_order_stats (
order_id bigint(20) unsigned NOT NULL,
date_created datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
num_items_sold int(11) UNSIGNED DEFAULT 0 NOT NULL,
gross_total double DEFAULT 0 NOT NULL,
coupon_total double DEFAULT 0 NOT NULL,
refund_total double DEFAULT 0 NOT NULL,
tax_total double DEFAULT 0 NOT NULL,
shipping_total double DEFAULT 0 NOT NULL,
net_total double DEFAULT 0 NOT NULL,
returning_customer boolean DEFAULT 0 NOT NULL,
status varchar(200) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (order_id),
KEY date_created (date_created),
KEY customer_id (customer_id),
KEY status (status)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_product_lookup (
order_item_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
variation_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NULL,
date_created datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
product_qty INT UNSIGNED NOT NULL,
product_net_revenue double DEFAULT 0 NOT NULL,
product_gross_revenue double DEFAULT 0 NOT NULL,
coupon_amount double DEFAULT 0 NOT NULL,
tax_amount double DEFAULT 0 NOT NULL,
shipping_amount double DEFAULT 0 NOT NULL,
shipping_tax_amount double DEFAULT 0 NOT NULL,
refund_amount double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_item_id),
KEY order_id (order_id),
KEY product_id (product_id),
KEY customer_id (customer_id),
KEY date_created (date_created)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_tax_lookup (
order_id BIGINT UNSIGNED NOT NULL,
tax_rate_id BIGINT UNSIGNED NOT NULL,
date_created datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
shipping_tax double DEFAULT 0 NOT NULL,
order_tax double DEFAULT 0 NOT NULL,
total_tax double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_id, tax_rate_id),
KEY tax_rate_id (tax_rate_id),
KEY date_created (date_created)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_coupon_lookup (
order_id BIGINT UNSIGNED NOT NULL,
coupon_id BIGINT UNSIGNED NOT NULL,
date_created datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
discount_amount double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_id, coupon_id),
KEY coupon_id (coupon_id),
KEY date_created (date_created)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_admin_notes (
note_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name varchar(255) NOT NULL,
type varchar(20) NOT NULL,
locale varchar(20) NOT NULL,
title longtext NOT NULL,
content longtext NOT NULL,
icon varchar(200) NOT NULL,
content_data longtext NULL default null,
status varchar(200) NOT NULL,
source varchar(200) NOT NULL,
date_created datetime NOT NULL default '0000-00-00 00:00:00',
date_reminder datetime NULL default null,
PRIMARY KEY (note_id)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_admin_note_actions (
action_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
note_id BIGINT UNSIGNED NOT NULL,
name varchar(255) NOT NULL,
label varchar(255) NOT NULL,
query longtext NOT NULL,
PRIMARY KEY (action_id),
KEY note_id (note_id)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_customer_lookup (
customer_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED DEFAULT NULL,
username varchar(60) DEFAULT '' NOT NULL,
first_name varchar(255) NOT NULL,
last_name varchar(255) NOT NULL,
email varchar(100) NOT NULL,
date_last_active timestamp NULL default null,
date_registered timestamp NULL default null,
country char(2) DEFAULT '' NOT NULL,
postcode varchar(20) DEFAULT '' NOT NULL,
city varchar(100) DEFAULT '' NOT NULL,
PRIMARY KEY (customer_id),
UNIQUE KEY user_id (user_id),
KEY email (email)
) $collate;
";
return $tables;
}
/**
* Create database tables.
*/
public static function create_db_tables() {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( self::get_schema() );
}
/**
* Install plugin.
*/
public static function install() {
// Create tables.
self::create_db_tables();
// Initialize report tables.
add_action( 'woocommerce_after_register_post_type', array( __CLASS__, 'regenerate_report_data' ), 20 );
}
/**
* Add the currency symbol (in addition to currency code) to each Order
* object in REST API responses. For use in formatCurrency().

View File

@ -0,0 +1,264 @@
<?php
/**
* Installation related functions and actions.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Admin_Install Class.
*/
class WC_Admin_Install {
/**
* Plugin version.
*
* @TODO: get this dynamically?
*/
const VERSION_NUMBER = '0.6.0';
/**
* Plugin version option name.
*/
const VERSION_OPTION = 'wc_admin_version';
/**
* DB updates and callbacks that need to be run per version.
*
* @var array
*/
private static $db_updates = array();
/**
* Hook in tabs.
*/
public static function init() {
add_action( 'init', array( __CLASS__, 'check_version' ), 5 );
add_filter( 'wpmu_drop_tables', array( __CLASS__, 'wpmu_drop_tables' ) );
// Add wc-admin report tables to list of WooCommerce tables.
add_filter( 'woocommerce_install_get_tables', array( __CLASS__, 'add_tables' ) );
}
/**
* Check WC Admin version and run the updater is required.
*
* This check is done on all requests and runs if the versions do not match.
*/
public static function check_version() {
if (
! defined( 'IFRAME_REQUEST' ) &&
version_compare( get_option( self::VERSION_OPTION ), self::VERSION_NUMBER, '<' )
) {
self::install();
do_action( 'wc_admin_updated' );
}
}
/**
* Install WC Admin.
*/
public static function install() {
if ( ! is_blog_installed() ) {
return;
}
// Check if we are not already running this routine.
if ( 'yes' === get_transient( 'wc_admin_installing' ) ) {
return;
}
// If we made it till here nothing is running yet, lets set the transient now.
set_transient( 'wc_admin_installing', 'yes', MINUTE_IN_SECONDS * 10 );
wc_maybe_define_constant( 'WC_ADMIN_INSTALLING', true );
self::create_tables();
WC_Admin_Reports_Sync::regenerate_report_data();
self::update_wc_admin_version();
delete_transient( 'wc_admin_installing' );
do_action( 'wc_admin_installed' );
}
/**
* Get database schema.
*
* @return string
*/
private static function get_schema() {
global $wpdb;
if ( $wpdb->has_cap( 'collation' ) ) {
$collate = $wpdb->get_charset_collate();
}
$tables = "
CREATE TABLE {$wpdb->prefix}wc_order_stats (
order_id bigint(20) unsigned NOT NULL,
date_created timestamp DEFAULT '0000-00-00 00:00:00' NOT NULL,
num_items_sold int(11) UNSIGNED DEFAULT 0 NOT NULL,
gross_total double DEFAULT 0 NOT NULL,
coupon_total double DEFAULT 0 NOT NULL,
refund_total double DEFAULT 0 NOT NULL,
tax_total double DEFAULT 0 NOT NULL,
shipping_total double DEFAULT 0 NOT NULL,
net_total double DEFAULT 0 NOT NULL,
returning_customer boolean DEFAULT 0 NOT NULL,
status varchar(200) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (order_id),
KEY date_created (date_created),
KEY customer_id (customer_id),
KEY status (status)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_product_lookup (
order_item_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
variation_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NULL,
date_created timestamp DEFAULT '0000-00-00 00:00:00' NOT NULL,
product_qty INT UNSIGNED NOT NULL,
product_net_revenue double DEFAULT 0 NOT NULL,
product_gross_revenue double DEFAULT 0 NOT NULL,
coupon_amount double DEFAULT 0 NOT NULL,
tax_amount double DEFAULT 0 NOT NULL,
shipping_amount double DEFAULT 0 NOT NULL,
shipping_tax_amount double DEFAULT 0 NOT NULL,
refund_amount double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_item_id),
KEY order_id (order_id),
KEY product_id (product_id),
KEY customer_id (customer_id),
KEY date_created (date_created)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_tax_lookup (
order_id BIGINT UNSIGNED NOT NULL,
tax_rate_id BIGINT UNSIGNED NOT NULL,
date_created timestamp DEFAULT '0000-00-00 00:00:00' NOT NULL,
shipping_tax double DEFAULT 0 NOT NULL,
order_tax double DEFAULT 0 NOT NULL,
total_tax double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_id, tax_rate_id),
KEY tax_rate_id (tax_rate_id),
KEY date_created (date_created)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_coupon_lookup (
order_id BIGINT UNSIGNED NOT NULL,
coupon_id BIGINT UNSIGNED NOT NULL,
date_created timestamp DEFAULT '0000-00-00 00:00:00' NOT NULL,
discount_amount double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_id, coupon_id),
KEY coupon_id (coupon_id),
KEY date_created (date_created)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_admin_notes (
note_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name varchar(255) NOT NULL,
type varchar(20) NOT NULL,
locale varchar(20) NOT NULL,
title longtext NOT NULL,
content longtext NOT NULL,
icon varchar(200) NOT NULL,
content_data longtext NULL default null,
status varchar(200) NOT NULL,
source varchar(200) NOT NULL,
date_created datetime NOT NULL default '0000-00-00 00:00:00',
date_reminder datetime NULL default null,
PRIMARY KEY (note_id)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_admin_note_actions (
action_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
note_id BIGINT UNSIGNED NOT NULL,
name varchar(255) NOT NULL,
label varchar(255) NOT NULL,
query longtext NOT NULL,
PRIMARY KEY (action_id),
KEY note_id (note_id)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_customer_lookup (
customer_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED DEFAULT NULL,
username varchar(60) DEFAULT '' NOT NULL,
first_name varchar(255) NOT NULL,
last_name varchar(255) NOT NULL,
email varchar(100) NOT NULL,
date_last_active timestamp NULL default null,
date_registered timestamp NULL default null,
country char(2) DEFAULT '' NOT NULL,
postcode varchar(20) DEFAULT '' NOT NULL,
city varchar(100) DEFAULT '' NOT NULL,
PRIMARY KEY (customer_id),
UNIQUE KEY user_id (user_id),
KEY email (email)
) $collate;
";
return $tables;
}
/**
* Create database tables.
*/
public static function create_tables() {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( self::get_schema() );
}
/**
* Return a list of tables. Used to make sure all WC Admin tables are dropped
* when uninstalling the plugin in a single site or multi site environment.
*
* @return array WC tables.
*/
public static function get_tables() {
global $wpdb;
return array(
"{$wpdb->prefix}wc_order_stats",
"{$wpdb->prefix}wc_order_product_lookup",
"{$wpdb->prefix}wc_order_tax_lookup",
"{$wpdb->prefix}wc_order_coupon_lookup",
"{$wpdb->prefix}wc_admin_notes",
"{$wpdb->prefix}wc_admin_note_actions",
"{$wpdb->prefix}wc_customer_lookup",
);
}
/**
* Adds new tables.
*
* @param array $wc_tables List of WooCommerce tables.
* @return array
*/
public static function add_tables( $wc_tables ) {
return array_merge(
$wc_tables,
self::get_tables()
);
}
/**
* Uninstall tables when MU blog is deleted.
*
* @param array $tables List of tables that will be deleted by WP.
*
* @return string[]
*/
public static function wpmu_drop_tables( $tables ) {
return array_merge( $tables, self::get_tables() );
}
/**
* Update WC Admin version to current.
*/
private static function update_wc_admin_version() {
delete_option( self::VERSION_OPTION );
add_option( self::VERSION_OPTION, self::VERSION_NUMBER );
}
}
WC_Admin_Install::init();

View File

@ -94,8 +94,8 @@ class WC_Admin_Reports_Segmenting {
}
foreach ( $segments_db_result as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
$segment_labels = $this->get_segment_labels();
$segment_id = $segment_data[ $segment_dimension ];
$segment_labels = $this->get_segment_labels();
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
@ -208,7 +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();
$segment_labels = $this->get_segment_labels();
foreach ( $result1 as $segment_data ) {
$time_interval = $segment_data['time_interval'];
@ -317,8 +317,8 @@ class WC_Admin_Reports_Segmenting {
$segment_objects = wc_get_products( $args );
foreach ( $segment_objects as $segment ) {
$id = $segment->get_id();
$segments[] = $id;
$id = $segment->get_id();
$segments[] = $id;
$segment_labels[ $id ] = $segment->get_name();
}
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
@ -342,17 +342,24 @@ class WC_Admin_Reports_Segmenting {
$segment_objects = wc_get_products( $args );
foreach ( $segment_objects as $segment ) {
$id = $segment->get_id();
$segments[] = $id;
$id = $segment->get_id();
$segments[] = $id;
$segment_labels[ $id ] = $segment->get_name();
}
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$categories = get_categories(
array(
'taxonomy' => 'product_cat',
)
$args = array(
'taxonomy' => 'product_cat',
);
$segments = wp_list_pluck( $categories, 'cat_ID' );
if ( isset( $this->query_args['categories'] ) ) {
$args['include'] = $this->query_args['categories'];
}
$categories = get_categories( $args );
$segments = wp_list_pluck( $categories, 'cat_ID' );
$segment_labels = wp_list_pluck( $categories, 'name', 'cat_ID' );
} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
// @todo Switch to a non-direct-SQL way to get all coupons?
// @todo These are only currently existing coupons, but we should add also deleted ones, if they have been used at least once.

View File

@ -0,0 +1,29 @@
<?php
/**
* Class for stock stats report querying
*
* $report = new WC_Admin_Reports_Stock__Stats_Query();
* $mydata = $report->get_data();
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Admin_Reports_Stock_Stats_Query
*/
class WC_Admin_Reports_Stock_Stats_Query extends WC_Admin_Reports_Query {
/**
* Get product data based on the current query vars.
*
* @return array
*/
public function get_data() {
$data_store = WC_Data_Store::load( 'report-stock-stats' );
$results = $data_store->get_data();
return apply_filters( 'woocommerce_reports_stock_stats_query', $results );
}
}

View File

@ -0,0 +1,411 @@
<?php
/**
* Report table sync related functions and actions.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Admin_Reports_Sync Class.
*/
class WC_Admin_Reports_Sync {
/**
* Action hook for reducing a range of batches down to single actions.
*/
const QUEUE_BATCH_ACTION = 'wc-admin_queue_batches';
/**
* Action hook for queuing an action after another is complete.
*/
const QUEUE_DEPEDENT_ACTION = 'wc-admin_queue_dependent_action';
/**
* Action hook for processing a batch of customers.
*/
const CUSTOMERS_BATCH_ACTION = 'wc-admin_process_customers_batch';
/**
* Action hook for processing a batch of orders.
*/
const ORDERS_BATCH_ACTION = 'wc-admin_process_orders_batch';
/**
* Action hook for initializing the orders lookup batch creation.
*/
const ORDERS_LOOKUP_BATCH_INIT = 'wc-admin_orders_lookup_batch_init';
/**
* Action hook for processing a batch of orders.
*/
const SINGLE_ORDER_ACTION = 'wc-admin_process_order';
/**
* Queue instance.
*
* @var WC_Queue_Interface
*/
protected static $queue = null;
/**
* Get queue instance.
*
* @return WC_Queue_Interface
*/
public static function queue() {
if ( is_null( self::$queue ) ) {
self::$queue = WC()->queue();
}
return self::$queue;
}
/**
* Set queue instance.
*
* @param WC_Queue_Interface $queue Queue instance.
*/
public static function set_queue( $queue ) {
self::$queue = $queue;
}
/**
* Hook in sync methods.
*/
public static function init() {
// Add report regeneration to tools menu.
add_filter( 'woocommerce_debug_tools', array( __CLASS__, 'add_regenerate_tool' ) );
// Initialize syncing hooks.
add_action( 'wp_loaded', array( __CLASS__, 'orders_lookup_update_init' ) );
// Initialize scheduled action handlers.
add_action( self::QUEUE_BATCH_ACTION, array( __CLASS__, 'queue_batches' ), 10, 3 );
add_action( self::QUEUE_DEPEDENT_ACTION, array( __CLASS__, 'queue_dependent_action' ), 10, 3 );
add_action( self::CUSTOMERS_BATCH_ACTION, array( __CLASS__, 'customer_lookup_process_batch' ) );
add_action( self::ORDERS_BATCH_ACTION, array( __CLASS__, 'orders_lookup_process_batch' ) );
add_action( self::ORDERS_LOOKUP_BATCH_INIT, array( __CLASS__, 'orders_lookup_batch_init' ) );
add_action( self::SINGLE_ORDER_ACTION, array( __CLASS__, 'orders_lookup_process_order' ) );
}
/**
* Regenerate data for reports.
*/
public static function regenerate_report_data() {
// Add registered customers to the lookup table before updating order stats
// so that the orders can be associated with the `customer_id` column.
self::customer_lookup_batch_init();
// Queue orders lookup to occur after customers lookup generation is done.
self::queue_dependent_action( self::ORDERS_LOOKUP_BATCH_INIT, array(), self::CUSTOMERS_BATCH_ACTION );
}
/**
* Adds regenerate tool.
*
* @param array $tools List of tools.
* @return array
*/
public static function add_regenerate_tool( $tools ) {
return array_merge(
$tools,
array(
'rebuild_stats' => array(
'name' => __( 'Rebuild reports data', 'wc-admin' ),
'button' => __( 'Rebuild reports', 'wc-admin' ),
'desc' => __( 'This tool will rebuild all of the information used by the reports.', 'wc-admin' ),
'callback' => array( __CLASS__, 'regenerate_report_data' ),
),
)
);
}
/**
* Schedule an action to process a single Order.
*
* @param int $order_id Order ID.
* @return void
*/
public static function schedule_single_order_process( $order_id ) {
if ( 'shop_order' !== get_post_type( $order_id ) ) {
return;
}
// This can get called multiple times for a single order, so we look
// for existing pending jobs for the same order to avoid duplicating efforts.
$existing_jobs = self::queue()->search(
array(
'status' => 'pending',
'per_page' => 1,
'claimed' => false,
'search' => "[{$order_id}]",
)
);
if ( $existing_jobs ) {
$existing_job = current( $existing_jobs );
// Bail out if there's a pending single order action, or a pending dependent action.
if (
( self::SINGLE_ORDER_ACTION === $existing_job->get_hook() ) ||
(
self::QUEUE_DEPEDENT_ACTION === $existing_job->get_hook() &&
in_array( self::SINGLE_ORDER_ACTION, $existing_job->get_args() )
)
) {
return;
}
}
// We want to ensure that customer lookup updates are scheduled before order updates.
self::queue_dependent_action( self::SINGLE_ORDER_ACTION, array( $order_id ), self::CUSTOMERS_BATCH_ACTION );
}
/**
* Attach order lookup update hooks.
*/
public static function orders_lookup_update_init() {
// Activate WC_Order extension.
WC_Admin_Order::add_filters();
add_action( 'save_post_shop_order', array( __CLASS__, 'schedule_single_order_process' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'schedule_single_order_process' ) );
WC_Admin_Reports_Orders_Stats_Data_Store::init();
WC_Admin_Reports_Customers_Data_Store::init();
WC_Admin_Reports_Coupons_Data_Store::init();
WC_Admin_Reports_Products_Data_Store::init();
WC_Admin_Reports_Taxes_Data_Store::init();
}
/**
* Init order/product lookup tables update (in batches).
*/
public static function orders_lookup_batch_init() {
$batch_size = self::get_batch_size( self::ORDERS_BATCH_ACTION );
$order_query = new WC_Order_Query(
array(
'return' => 'ids',
'limit' => 1,
'paginate' => true,
)
);
$result = $order_query->get_orders();
if ( 0 === $result->total ) {
return;
}
$num_batches = ceil( $result->total / $batch_size );
self::queue_batches( 1, $num_batches, self::ORDERS_BATCH_ACTION );
}
/**
* Process a batch of orders to update (stats and products).
*
* @param int $batch_number Batch number to process (essentially a query page number).
* @return void
*/
public static function orders_lookup_process_batch( $batch_number ) {
$batch_size = self::get_batch_size( self::ORDERS_BATCH_ACTION );
$order_query = new WC_Order_Query(
array(
'return' => 'ids',
'limit' => $batch_size,
'page' => $batch_number,
'orderby' => 'ID',
'order' => 'ASC',
)
);
$order_ids = $order_query->get_orders();
foreach ( $order_ids as $order_id ) {
self::orders_lookup_process_order( $order_id );
}
}
/**
* Process a single order to update lookup tables for.
* If an error is encountered in one of the updates, a retry action is scheduled.
*
* @param int $order_id Order ID.
* @return void
*/
public static function orders_lookup_process_order( $order_id ) {
$result = array_sum(
array(
WC_Admin_Reports_Orders_Stats_Data_Store::sync_order( $order_id ),
WC_Admin_Reports_Products_Data_Store::sync_order_products( $order_id ),
WC_Admin_Reports_Coupons_Data_Store::sync_order_coupons( $order_id ),
WC_Admin_Reports_Taxes_Data_Store::sync_order_taxes( $order_id ),
)
);
// If all updates were either skipped or successful, we're done.
// The update methods return -1 for skip, or a boolean success indicator.
if ( 4 === absint( $result ) ) {
return;
}
// Otherwise assume an error occurred and reschedule.
self::schedule_single_order_process( $order_id );
}
/**
* Returns the batch size for regenerating reports.
* Note: can differ per batch action.
*
* @param string $action Single batch action name.
* @return int Batch size.
*/
public static function get_batch_size( $action ) {
$batch_sizes = array(
self::QUEUE_BATCH_ACTION => 100,
self::CUSTOMERS_BATCH_ACTION => 25,
self::ORDERS_BATCH_ACTION => 10,
);
$batch_size = isset( $batch_sizes[ $action ] ) ? $batch_sizes[ $action ] : 25;
/**
* Filter the batch size for regenerating a report table.
*
* @param int $batch_size Batch size.
* @param string $action Batch action name.
*/
return apply_filters( 'wc_admin_report_regenerate_batch_size', $batch_size, $action );
}
/**
* Queue a large number of batch jobs, respecting the batch size limit.
* Reduces a range of batches down to "single batch" jobs.
*
* @param int $range_start Starting batch number.
* @param int $range_end Ending batch number.
* @param string $single_batch_action Action to schedule for a single batch.
* @return void
*/
public static function queue_batches( $range_start, $range_end, $single_batch_action ) {
$batch_size = self::get_batch_size( self::QUEUE_BATCH_ACTION );
$range_size = 1 + ( $range_end - $range_start );
$action_timestamp = time() + 5;
if ( $range_size > $batch_size ) {
// If the current batch range is larger than a single batch,
// split the range into $queue_batch_size chunks.
$chunk_size = ceil( $range_size / $batch_size );
for ( $i = 0; $i < $batch_size; $i++ ) {
$batch_start = $range_start + ( $i * $chunk_size );
$batch_end = min( $range_end, $range_start + ( $chunk_size * ( $i + 1 ) ) - 1 );
self::queue()->schedule_single(
$action_timestamp,
self::QUEUE_BATCH_ACTION,
array( $batch_start, $batch_end, $single_batch_action )
);
}
} else {
// Otherwise, queue the single batches.
for ( $i = $range_start; $i <= $range_end; $i++ ) {
self::queue()->schedule_single( $action_timestamp, $single_batch_action, array( $i ) );
}
}
}
/**
* Queue an action to run after another.
*
* @param string $action Action to run after prerequisite.
* @param array $action_args Action arguments.
* @param string $prerequisite_action Prerequisite action.
*/
public static function queue_dependent_action( $action, $action_args, $prerequisite_action ) {
$blocking_jobs = self::queue()->search(
array(
'status' => 'pending',
'orderby' => 'date',
'order' => 'DESC',
'per_page' => 1,
'claimed' => false,
'search' => $prerequisite_action, // search is used instead of hook to find queued batch creation.
)
);
$next_job_schedule = null;
$blocking_job_hook = null;
if ( $blocking_jobs ) {
$blocking_job = current( $blocking_jobs );
$blocking_job_hook = $blocking_job->get_hook();
$next_job_schedule = $blocking_job->get_schedule()->next();
}
// Eliminate the false positive scenario where the blocking job is
// actually another queued dependent action awaiting the same prerequisite.
// Also, ensure that the next schedule is a DateTime (it can be null).
if (
is_a( $next_job_schedule, 'DateTime' ) &&
( self::QUEUE_DEPEDENT_ACTION !== $blocking_job_hook )
) {
self::queue()->schedule_single(
$next_job_schedule->getTimestamp() + 5,
self::QUEUE_DEPEDENT_ACTION,
array( $action, $action_args, $prerequisite_action )
);
} else {
self::queue()->schedule_single( time() + 5, $action, $action_args );
}
}
/**
* Init customer lookup table update (in batches).
*/
public static function customer_lookup_batch_init() {
$batch_size = self::get_batch_size( self::CUSTOMERS_BATCH_ACTION );
$customer_query = new WP_User_Query(
array(
'fields' => 'ID',
'number' => 1,
)
);
$total_customers = $customer_query->get_total();
if ( 0 === $total_customers ) {
return;
}
$num_batches = ceil( $total_customers / $batch_size );
self::queue_batches( 1, $num_batches, self::CUSTOMERS_BATCH_ACTION );
}
/**
* Process a batch of customers to update.
*
* @param int $batch_number Batch number to process (essentially a query page number).
* @return void
*/
public static function customer_lookup_process_batch( $batch_number ) {
$batch_size = self::get_batch_size( self::CUSTOMERS_BATCH_ACTION );
$customer_query = new WP_User_Query(
array(
'fields' => 'ID',
'orderby' => 'ID',
'order' => 'ASC',
'number' => $batch_size,
'paged' => $batch_number,
)
);
$customer_ids = $customer_query->get_results();
foreach ( $customer_ids as $customer_id ) {
// @todo Schedule single customer update if this fails?
WC_Admin_Reports_Customers_Data_Store::update_registered_customer( $customer_id );
}
}
}
WC_Admin_Reports_Sync::init();

View File

@ -25,7 +25,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
* @var array
*/
protected $column_types = array(
'customer_id' => 'intval',
'id' => 'intval',
'user_id' => 'intval',
'orders_count' => 'intval',
'total_spend' => 'floatval',
@ -38,7 +38,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
* @var array
*/
protected $report_columns = array(
'customer_id' => 'customer_id',
'id' => 'customer_id as id',
'user_id' => 'user_id',
'username' => 'username',
'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @todo What does this mean for RTL?
@ -60,7 +60,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
global $wpdb;
// Initialize some report columns that need disambiguation.
$this->report_columns['customer_id'] = $wpdb->prefix . self::TABLE_NAME . '.customer_id';
$this->report_columns['id'] = $wpdb->prefix . self::TABLE_NAME . '.customer_id as id';
$this->report_columns['date_last_order'] = "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order";
}
@ -230,8 +230,15 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
}
}
if ( ! empty( $query_args['name'] ) ) {
$where_clauses[] = $wpdb->prepare( "CONCAT_WS( ' ', first_name, last_name ) = %s", $query_args['name'] );
if ( ! empty( $query_args['search'] ) ) {
$name_like = '%' . $wpdb->esc_like( $query_args['search'] ) . '%';
$where_clauses[] = $wpdb->prepare( "CONCAT_WS( ' ', first_name, last_name ) LIKE %s", $name_like );
}
// Allow a list of customer IDs to be specified.
if ( ! empty( $query_args['customers'] ) ) {
$included_customers = implode( ',', $query_args['customers'] );
$where_clauses[] = "{$customer_lookup_table}.customer_id IN ({$included_customers})";
}
$numeric_params = array(
@ -253,17 +260,18 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
$subclauses = array();
$min_param = $numeric_param . '_min';
$max_param = $numeric_param . '_max';
$or_equal = isset( $query_args[ $min_param ] ) && isset( $query_args[ $max_param ] ) ? '=' : '';
if ( isset( $query_args[ $min_param ] ) ) {
$subclauses[] = $wpdb->prepare(
"{$param_info['column']} >= {$param_info['format']}",
"{$param_info['column']} >{$or_equal} {$param_info['format']}",
$query_args[ $min_param ]
); // WPCS: unprepared SQL ok.
}
if ( isset( $query_args[ $max_param ] ) ) {
$subclauses[] = $wpdb->prepare(
"{$param_info['column']} <= {$param_info['format']}",
"{$param_info['column']} <{$or_equal} {$param_info['format']}",
$query_args[ $max_param ]
); // WPCS: unprepared SQL ok.
}
@ -517,6 +525,30 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
return $customer_id ? (int) $customer_id : false;
}
/**
* Retrieve the oldest orders made by a customer.
*
* @param int $customer_id Customer ID.
* @return array Orders.
*/
public static function get_oldest_orders( $customer_id ) {
global $wpdb;
$orders_table = $wpdb->prefix . 'wc_order_stats';
$excluded_statuses = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() );
$excluded_statuses_condition = '';
if ( ! empty( $excluded_statuses ) ) {
$excluded_statuses_str = implode( "','", $excluded_statuses );
$excluded_statuses_condition = "AND status NOT IN ('{$excluded_statuses_str}')";
}
return $wpdb->get_results(
$wpdb->prepare(
"SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2",
$customer_id
)
); // WPCS: unprepared SQL ok.
}
/**
* Update the database with customer data.
*

View File

@ -184,43 +184,17 @@ class WC_Admin_Reports_Downloads_Stats_Data_Store extends WC_Admin_Reports_Downl
}
/**
* Sorts intervals according to user's request.
* Normalizes order_by clause to match to SQL query.
*
* They are pre-sorted in SQL, but after adding gaps, they need to be sorted including the added ones.
*
* @param stdClass $data Data object, must contain an array under $data->intervals.
* @param string $sort_by Ordering property.
* @param string $direction DESC/ASC.
*/
protected function sort_intervals( &$data, $sort_by, $direction ) {
if ( 'date' === $sort_by ) {
$this->order_by = 'time_interval';
} else {
$this->order_by = $sort_by;
}
$this->order = $direction;
usort( $data->intervals, array( $this, 'interval_cmp' ) );
}
/**
* Compares two report data objects by pre-defined object property and ASC/DESC ordering.
*
* @param stdClass $a Object a.
* @param stdClass $b Object b.
* @param string $order_by Order by option requeste by user.
* @return string
*/
protected function interval_cmp( $a, $b ) {
if ( '' === $this->order_by || '' === $this->order ) {
return 0;
}
if ( $a[ $this->order_by ] === $b[ $this->order_by ] ) {
return 0;
} elseif ( $a[ $this->order_by ] > $b[ $this->order_by ] ) {
return strtolower( $this->order ) === 'desc' ? -1 : 1;
} elseif ( $a[ $this->order_by ] < $b[ $this->order_by ] ) {
return strtolower( $this->order ) === 'desc' ? 1 : -1;
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
return $order_by;
}
}

View File

@ -227,6 +227,8 @@ class WC_Admin_Reports_Orders_Data_Store extends WC_Admin_Reports_Data_Store imp
if ( 'date' === $order_by ) {
return 'date_created';
}
return $order_by;
}
/**
@ -313,7 +315,7 @@ class WC_Admin_Reports_Orders_Data_Store extends WC_Admin_Reports_Data_Store imp
"SELECT order_id, ID as product_id, post_title as product_name, product_qty as product_quantity
FROM {$wpdb->prefix}posts
JOIN {$order_product_lookup_table} ON {$order_product_lookup_table}.product_id = {$wpdb->prefix}posts.ID
WHERE
WHERE
order_id IN ({$included_order_ids})
",
ARRAY_A
@ -337,7 +339,7 @@ class WC_Admin_Reports_Orders_Data_Store extends WC_Admin_Reports_Data_Store imp
"SELECT order_id, coupon_id, post_title as coupon_code
FROM {$wpdb->prefix}posts
JOIN {$order_coupon_lookup_table} ON {$order_coupon_lookup_table}.coupon_id = {$wpdb->prefix}posts.ID
WHERE
WHERE
order_id IN ({$included_order_ids})
",
ARRAY_A

View File

@ -514,24 +514,64 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
* @return bool
*/
protected static function is_returning_customer( $order ) {
global $wpdb;
$customer_id = WC_Admin_Reports_Customers_Data_Store::get_customer_id_by_user_id( $order->get_user_id() );
$orders_stats_table = $wpdb->prefix . self::TABLE_NAME;
$customer_id = WC_Admin_Reports_Customers_Data_Store::get_customer_id_by_user_id( $order->get_user_id() );
if ( ! $customer_id ) {
return false;
}
$customer_orders = $wpdb->get_var(
$oldest_orders = WC_Admin_Reports_Customers_Data_Store::get_oldest_orders( $customer_id );
if ( empty( $oldest_orders ) ) {
return false;
}
$first_order = $oldest_orders[0];
$second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false;
$excluded_statuses = self::get_excluded_report_order_statuses();
// Order is older than previous first order.
if ( $order->get_date_created() < new WC_DateTime( $first_order->date_created ) &&
! in_array( $order->get_status(), $excluded_statuses, true )
) {
self::set_customer_first_order( $customer_id, $order->get_id() );
return false;
}
// The current order is the oldest known order.
$is_first_order = (int) $order->get_id() === (int) $first_order->order_id;
// Order date has changed and next oldest is now the first order.
$date_change = $second_order &&
$order->get_date_created() > new WC_DateTime( $first_order->date_created ) &&
new WC_DateTime( $second_order->date_created ) < $order->get_date_created();
// Status has changed to an excluded status and next oldest order is now the first order.
$status_change = $second_order &&
in_array( $order->get_status(), $excluded_statuses, true );
if ( $is_first_order && ( $date_change || $status_change ) ) {
self::set_customer_first_order( $customer_id, $second_order->order_id );
return true;
}
return (int) $order->get_id() !== (int) $first_order->order_id;
}
/**
* Set a customer's first order and all others to returning.
*
* @param int $customer_id Customer ID.
* @param int $order_id Order ID.
*/
protected static function set_customer_first_order( $customer_id, $order_id ) {
global $wpdb;
$orders_stats_table = $wpdb->prefix . self::TABLE_NAME;
$wpdb->query(
$wpdb->prepare(
"SELECT COUNT(*) FROM ${orders_stats_table} WHERE customer_id = %d AND date_created < %s AND order_id != %d",
$customer_id,
date( 'Y-m-d H:i:s', $order->get_date_created()->getTimestamp() ),
$order->get_id()
"UPDATE ${orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d",
$order_id,
$customer_id
)
);
return $customer_orders >= 1;
}
/**

View File

@ -0,0 +1,138 @@
<?php
/**
* WC_Admin_Reports_Stock_Stats_Data_Store class file.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Reports_Stock_Stats_Data_Store.
*/
class WC_Admin_Reports_Stock_Stats_Data_Store extends WC_Admin_Reports_Data_Store implements WC_Admin_Reports_Data_Store_Interface {
/**
* Get stock counts for the whole store.
*
* @param array $query Not used for the stock stats data store, but needed for the interface.
* @return array Array of counts.
*/
public function get_data( $query ) {
$report_data = array();
$cache_expire = DAY_IN_SECONDS * 30;
$low_stock_transient_name = 'wc_admin_stock_count_lowstock';
$low_stock_count = get_transient( $low_stock_transient_name );
if ( false === $low_stock_count ) {
$low_stock_count = $this->get_low_stock_count();
set_transient( $low_stock_transient_name, $low_stock_count, $cache_expire );
}
$report_data['lowstock'] = $low_stock_count;
$status_options = wc_get_product_stock_status_options();
foreach ( $status_options as $status => $label ) {
$transient_name = 'wc_admin_stock_count_' . $status;
$count = get_transient( $transient_name );
if ( false === $count ) {
$count = $this->get_count( $status );
set_transient( $transient_name, $count, $cache_expire );
}
$report_data[ $status ] = $count;
}
$product_count_transient_name = 'wc_admin_product_count';
$product_count = get_transient( $product_count_transient_name );
if ( false === $product_count ) {
$product_count = $this->get_product_count();
set_transient( $product_count_transient_name, $product_count, $cache_expire );
}
$report_data['products'] = $product_count;
return $report_data;
}
/**
* Get low stock count.
*
* @return int Low stock count.
*/
private function get_low_stock_count() {
$query_args = array();
$query_args['post_type'] = array( 'product', 'product_variation' );
$low_stock = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) );
$no_stock = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) );
$query_args['meta_query'] = array( // WPCS: slow query ok.
array(
'key' => '_manage_stock',
'value' => 'yes',
),
array(
'key' => '_stock',
'value' => array( $no_stock, $low_stock ),
'compare' => 'BETWEEN',
'type' => 'NUMERIC',
),
array(
'key' => '_stock_status',
'value' => 'instock',
),
);
$query = new WP_Query();
$query->query( $query_args );
return intval( $query->found_posts );
}
/**
* Get count for the passed in stock status.
*
* @param string $status Status slug.
* @return int Count.
*/
private function get_count( $status ) {
$query_args = array();
$query_args['post_type'] = array( 'product', 'product_variation' );
$query_args['meta_query'] = array( // WPCS: slow query ok.
array(
'key' => '_stock_status',
'value' => $status,
),
);
$query = new WP_Query();
$query->query( $query_args );
return intval( $query->found_posts );
}
/**
* Get product count for the store.
*
* @return int Product count.
*/
private function get_product_count() {
$query_args = array();
$query_args['post_type'] = array( 'product', 'product_variation' );
$query = new WP_Query();
$query->query( $query_args );
return intval( $query->found_posts );
}
}
/**
* Clear the count cache when products are added or updated, or when
* the no/low stock options are changed.
*
* @param int $id Post/product ID.
*/
function wc_admin_clear_stock_count_cache( $id ) {
delete_transient( 'wc_admin_stock_count_lowstock' );
delete_transient( 'wc_admin_product_count' );
$status_options = wc_get_product_stock_status_options();
foreach ( $status_options as $status => $label ) {
delete_transient( 'wc_admin_stock_count_' . $status );
}
}
add_action( 'woocommerce_update_product', 'wc_admin_clear_stock_count_cache' );
add_action( 'woocommerce_new_product', 'wc_admin_clear_stock_count_cache' );
add_action( 'update_option_woocommerce_notify_low_stock_amount', 'wc_admin_clear_stock_count_cache' );
add_action( 'update_option_woocommerce_notify_no_stock_amount', 'wc_admin_clear_stock_count_cache' );

View File

@ -21,6 +21,10 @@ function wc_admin_is_admin_page() {
* `wc_get_screen_ids` will also return IDs for extensions that have properly registered themselves.
*/
function wc_admin_is_embed_enabled_wc_page() {
if ( ! wc_admin_is_feature_enabled( 'activity-panels' ) ) {
return false;
}
$screen_id = wc_admin_get_current_screen_id();
if ( ! $screen_id ) {
return false;
@ -66,106 +70,110 @@ function wc_admin_register_page( $options ) {
function wc_admin_register_pages() {
global $menu, $submenu;
add_submenu_page(
'woocommerce',
__( 'WooCommerce Dashboard', 'wc-admin' ),
__( 'Dashboard', 'wc-admin' ),
'manage_options',
'wc-admin',
'wc_admin_page'
);
if ( wc_admin_is_feature_enabled( 'dashboard' ) ) {
add_submenu_page(
'woocommerce',
__( 'WooCommerce Dashboard', 'wc-admin' ),
__( 'Dashboard', 'wc-admin' ),
'manage_options',
'wc-admin',
'wc_admin_page'
);
}
add_menu_page(
__( 'WooCommerce Analytics', 'wc-admin' ),
__( 'Analytics', 'wc-admin' ),
'manage_options',
'wc-admin#/analytics/revenue',
'wc_admin_page',
'dashicons-chart-bar',
56 // After WooCommerce & Product menu items.
);
if ( wc_admin_is_feature_enabled( 'analytics' ) ) {
add_menu_page(
__( 'WooCommerce Analytics', 'wc-admin' ),
__( 'Analytics', 'wc-admin' ),
'manage_options',
'wc-admin#/analytics/revenue',
'wc_admin_page',
'dashicons-chart-bar',
56 // After WooCommerce & Product menu items.
);
wc_admin_register_page(
array(
'title' => __( 'Revenue', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/revenue',
)
);
wc_admin_register_page(
array(
'title' => __( 'Revenue', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/revenue',
)
);
wc_admin_register_page(
array(
'title' => __( 'Orders', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/orders',
)
);
wc_admin_register_page(
array(
'title' => __( 'Orders', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/orders',
)
);
wc_admin_register_page(
array(
'title' => __( 'Products', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/products',
)
);
wc_admin_register_page(
array(
'title' => __( 'Products', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/products',
)
);
wc_admin_register_page(
array(
'title' => __( 'Categories', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/categories',
)
);
wc_admin_register_page(
array(
'title' => __( 'Categories', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/categories',
)
);
wc_admin_register_page(
array(
'title' => __( 'Coupons', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/coupons',
)
);
wc_admin_register_page(
array(
'title' => __( 'Coupons', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/coupons',
)
);
wc_admin_register_page(
array(
'title' => __( 'Taxes', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/taxes',
)
);
wc_admin_register_page(
array(
'title' => __( 'Taxes', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/taxes',
)
);
wc_admin_register_page(
array(
'title' => __( 'Downloads', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/downloads',
)
);
wc_admin_register_page(
array(
'title' => __( 'Downloads', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/downloads',
)
);
wc_admin_register_page(
array(
'title' => __( 'Stock', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/stock',
)
);
wc_admin_register_page(
array(
'title' => __( 'Stock', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/stock',
)
);
wc_admin_register_page(
array(
'title' => __( 'Customers', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/customers',
)
);
wc_admin_register_page(
array(
'title' => __( 'Customers', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/customers',
)
);
wc_admin_register_page(
array(
'title' => __( 'Settings', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/settings',
)
);
wc_admin_register_page(
array(
'title' => __( 'Settings', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/settings',
)
);
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
if ( wc_admin_is_feature_enabled( 'devdocs' ) && defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
wc_admin_register_page(
array(
'title' => 'DevDocs',
@ -236,7 +244,7 @@ function wc_admin_enqueue_script() {
add_action( 'admin_enqueue_scripts', 'wc_admin_enqueue_script' );
/**
* Adds an admin body class.
* Adds body classes to the main wp-admin wrapper, allowing us to better target elements in specific scenarios.
*
* @param string $admin_body_class Body class to add.
*/
@ -252,6 +260,18 @@ function wc_admin_admin_body_class( $admin_body_class = '' ) {
if ( wc_admin_is_embed_enabled_wc_page() ) {
$classes[] = 'woocommerce-embed-page';
}
if ( function_exists( 'wc_admin_get_feature_config' ) ) {
$features = wc_admin_get_feature_config();
foreach ( $features as $feature_key => $bool ) {
if ( true === $bool ) {
$classes[] = sanitize_html_class( 'woocommerce-feature-enabled-' . $feature_key );
} else {
$classes[] = sanitize_html_class( 'woocommerce-feature-disabled-' . $feature_key );
}
}
}
$admin_body_class = implode( ' ', array_unique( $classes ) );
return " $admin_body_class ";
}
@ -261,7 +281,7 @@ add_filter( 'admin_body_class', 'wc_admin_admin_body_class' );
* Runs before admin notices action and hides them.
*/
function wc_admin_admin_before_notices() {
if ( ! wc_admin_is_admin_page() && ! wc_admin_is_embed_enabled_wc_page() ) {
if ( ( ! wc_admin_is_admin_page() && ! wc_admin_is_embed_enabled_wc_page() ) || ! wc_admin_is_feature_enabled( 'activity-panels' ) ) {
return;
}
echo '<div class="woocommerce-layout__notice-list-hide" id="wp__notice-list">';
@ -273,7 +293,7 @@ add_action( 'admin_notices', 'wc_admin_admin_before_notices', 0 );
* Runs after admin notices and closes div.
*/
function wc_admin_admin_after_notices() {
if ( ! wc_admin_is_admin_page() && ! wc_admin_is_embed_enabled_wc_page() ) {
if ( ( ! wc_admin_is_admin_page() && ! wc_admin_is_embed_enabled_wc_page() ) || ! wc_admin_is_feature_enabled( 'activity-panels' ) ) {
return;
}
echo '</div>';

View File

@ -280,3 +280,18 @@ function wc_admin_currency_settings() {
)
);
}
/**
* Returns if a specific wc-admin feature is enabled.
*
* @param string $feature Feature slug.
* @return bool Returns true if the feature is enabled.
* }
*/
function wc_admin_is_feature_enabled( $feature ) {
if ( ! function_exists( 'wc_admin_get_feature_config' ) ) {
return false;
}
$features = wc_admin_get_feature_config();
return isset( $features[ $feature ] ) && true === $features[ $feature ];
}

View File

@ -1,6 +1,6 @@
{
"name": "wc-admin",
"version": "0.6.0",
"version": "0.7.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -8851,8 +8851,7 @@
},
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"optional": true
"bundled": true
},
"aproba": {
"version": "1.2.0",
@ -8870,13 +8869,11 @@
},
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"optional": true
"bundled": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -8889,18 +8886,15 @@
},
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"optional": true
"bundled": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"optional": true
"bundled": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"optional": true
"bundled": true
},
"core-util-is": {
"version": "1.0.2",
@ -9003,8 +8997,7 @@
},
"inherits": {
"version": "2.0.3",
"bundled": true,
"optional": true
"bundled": true
},
"ini": {
"version": "1.3.5",
@ -9014,7 +9007,6 @@
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -9027,20 +9019,17 @@
"minimatch": {
"version": "3.0.4",
"bundled": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"bundled": true,
"optional": true
"bundled": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -9057,7 +9046,6 @@
"mkdirp": {
"version": "0.5.1",
"bundled": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -9130,8 +9118,7 @@
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"optional": true
"bundled": true
},
"object-assign": {
"version": "4.1.1",
@ -9141,7 +9128,6 @@
"once": {
"version": "1.4.0",
"bundled": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -9217,8 +9203,7 @@
},
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"optional": true
"bundled": true
},
"safer-buffer": {
"version": "2.1.2",
@ -9248,7 +9233,6 @@
"string-width": {
"version": "1.0.2",
"bundled": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -9266,7 +9250,6 @@
"strip-ansi": {
"version": "3.0.1",
"bundled": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -9305,13 +9288,11 @@
},
"wrappy": {
"version": "1.0.2",
"bundled": true,
"optional": true
"bundled": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"optional": true
"bundled": true
}
}
},

View File

@ -1,6 +1,6 @@
{
"name": "wc-admin",
"version": "0.6.0",
"version": "0.7.0",
"main": "js/index.js",
"author": "Automattic",
"license": "GPL-2.0-or-later",
@ -16,13 +16,14 @@
"prebuild": "npm run -s install-if-deps-outdated",
"build:packages": "node ./bin/packages/build.js",
"build:core": "cross-env NODE_ENV=production webpack",
"build": "npm run build:packages && npm run build:core",
"build:release": "./bin/build-plugin-zip.sh",
"build": "npm run build:feature-config && npm run build:packages && npm run build:core",
"build:release": "cross-env WC_ADMIN_PHASE=plugin ./bin/build-plugin-zip.sh",
"build:feature-config": "php bin/generate-feature-config.php",
"postbuild": "npm run -s i18n:php && npm run -s i18n:pot",
"postshrinkwrap": "replace --silent 'http://' 'https://' ./package-lock.json",
"prestart": "npm run -s install-if-deps-outdated",
"dev:packages": "node ./bin/packages/watch.js",
"start": "npm run build:packages && concurrently \"cross-env webpack --watch\" \"npm run dev:packages\"",
"start": "cross-env WC_ADMIN_PHASE=development npm run build:packages && cross-env WC_ADMIN_PHASE=development npm run build:feature-config && concurrently \"cross-env WC_ADMIN_PHASE=development webpack --watch\" \"npm run dev:packages\"",
"i18n:js": "cross-env NODE_ENV=production babel client -o /dev/null",
"i18n:php": "pot-to-php ./languages/wc-admin.pot ./languages/wc-admin.php wc-admin",
"i18n:pot": "grunt makepot",

View File

@ -1,4 +1,5 @@
# 1.5.0 (unreleased)
- Chart component: new props `emptyMessage` and `baseValue`. When an empty message is provided, it will be displayed on top of the chart if there are no values different than `baseValue`.
- Chart component: remove d3-array dependency.
- Chart component: fix display when there is no data.
- Improves display of charts where all values are 0.

View File

@ -6,20 +6,17 @@
import { Component, createRef } from '@wordpress/element';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { timeFormat as d3TimeFormat, utcParse as d3UTCParse } from 'd3-time-format';
import { timeFormat as d3TimeFormat } from 'd3-time-format';
/**
* Internal dependencies
*/
import D3Base from './d3base';
import {
getDateSpaces,
getOrderedKeys,
getLine,
getLineData,
getUniqueKeys,
getUniqueDates,
getFormatter,
isDataEmpty,
} from './utils/index';
import {
getXScale,
@ -27,11 +24,11 @@ import {
getXLineScale,
getYMax,
getYScale,
getYTickOffset,
} from './utils/scales';
import { drawAxis, getXTicks } from './utils/axis';
import { drawAxis } from './utils/axis';
import { drawBars } from './utils/bar-chart';
import { drawLines } from './utils/line-chart';
import ChartTooltip from './utils/tooltip';
/**
* A simple D3 line and bar chart component for timeseries data in React.
@ -44,27 +41,89 @@ class D3Chart extends Component {
this.tooltipRef = createRef();
}
getFormatParams() {
const { xFormat, x2Format, yFormat } = this.props;
return {
xFormat: getFormatter( xFormat, d3TimeFormat ),
x2Format: getFormatter( x2Format, d3TimeFormat ),
yFormat: getFormatter( yFormat ),
};
}
getScaleParams( uniqueDates ) {
const { data, height, margin, orderedKeys, type } = this.props;
const adjHeight = height - margin.top - margin.bottom;
const adjWidth = this.getWidth() - margin.left - margin.right;
const yMax = getYMax( data );
const yScale = getYScale( adjHeight, yMax );
if ( type === 'line' ) {
return {
xScale: getXLineScale( uniqueDates, adjWidth ),
yMax,
yScale,
};
}
const compact = this.shouldBeCompact();
const xScale = getXScale( uniqueDates, adjWidth, compact );
return {
xGroupScale: getXGroupScale( orderedKeys, xScale, compact ),
xScale,
yMax,
yScale,
};
}
getParams( uniqueDates ) {
const { colorScheme, data, interval, mode, orderedKeys, type } = this.props;
const newOrderedKeys = orderedKeys || getOrderedKeys( data );
return {
colorScheme,
interval,
mode,
type,
uniqueDates,
visibleKeys: newOrderedKeys.filter( key => key.visible ),
};
}
createTooltip( chart, visibleKeys ) {
const { colorScheme, tooltipLabelFormat, tooltipPosition, tooltipTitle, tooltipValueFormat } = this.props;
const tooltip = new ChartTooltip();
tooltip.ref = this.tooltipRef.current;
tooltip.chart = chart;
tooltip.position = tooltipPosition;
tooltip.title = tooltipTitle;
tooltip.labelFormat = getFormatter( tooltipLabelFormat, d3TimeFormat );
tooltip.valueFormat = getFormatter( tooltipValueFormat );
tooltip.visibleKeys = visibleKeys;
tooltip.colorScheme = colorScheme;
this.tooltip = tooltip;
}
drawChart( node ) {
const { data, margin, type } = this.props;
const params = this.getParams();
const adjParams = Object.assign( {}, params, {
height: params.adjHeight,
width: params.adjWidth,
tooltip: this.tooltipRef.current,
valueType: params.valueType,
} );
const { data, dateParser, margin, type } = this.props;
const uniqueDates = getUniqueDates( data, dateParser );
const formats = this.getFormatParams();
const params = this.getParams( uniqueDates );
const scales = this.getScaleParams( uniqueDates );
const g = node
.attr( 'id', 'chart' )
.append( 'g' )
.attr( 'transform', `translate(${ margin.left },${ margin.top })` );
.attr( 'transform', `translate(${ margin.left }, ${ margin.top })` );
const xOffset = type === 'line' && adjParams.uniqueDates.length <= 1
? adjParams.width / 2
: 0;
drawAxis( g, adjParams, xOffset );
type === 'line' && drawLines( g, data, adjParams, xOffset );
type === 'bar' && drawBars( g, data, adjParams );
this.createTooltip( g.node(), params.visibleKeys );
drawAxis( g, params, scales, formats, margin );
type === 'line' && drawLines( g, data, params, scales, formats, this.tooltip );
type === 'bar' && drawBars( g, data, params, scales, formats, this.tooltip );
}
shouldBeCompact() {
@ -90,90 +149,33 @@ class D3Chart extends Component {
return Math.max( width, minimumWidth + margin.left + margin.right );
}
getParams() {
const {
colorScheme,
data,
dateParser,
height,
interval,
margin,
mode,
orderedKeys,
tooltipPosition,
tooltipLabelFormat,
tooltipValueFormat,
tooltipTitle,
type,
xFormat,
x2Format,
yFormat,
valueType,
} = this.props;
const adjHeight = height - margin.top - margin.bottom;
const adjWidth = this.getWidth() - margin.left - margin.right;
const compact = this.shouldBeCompact();
const uniqueKeys = getUniqueKeys( data );
const newOrderedKeys = orderedKeys || getOrderedKeys( data, uniqueKeys );
const visibleKeys = newOrderedKeys.filter( key => key.visible );
const lineData = getLineData( data, newOrderedKeys );
const yMax = getYMax( lineData );
const yScale = getYScale( adjHeight, yMax );
const parseDate = d3UTCParse( dateParser );
const uniqueDates = getUniqueDates( lineData, parseDate );
const xLineScale = getXLineScale( uniqueDates, adjWidth );
const xScale = getXScale( uniqueDates, adjWidth, compact );
const xTicks = getXTicks( uniqueDates, adjWidth, mode, interval );
return {
adjHeight,
adjWidth,
colorScheme,
dateSpaces: getDateSpaces( data, uniqueDates, adjWidth, xLineScale ),
interval,
line: getLine( xLineScale, yScale ),
lineData,
margin,
mode,
orderedKeys: newOrderedKeys,
visibleKeys,
parseDate,
tooltipPosition,
tooltipLabelFormat: getFormatter( tooltipLabelFormat, d3TimeFormat ),
tooltipValueFormat: getFormatter( tooltipValueFormat ),
tooltipTitle,
type,
uniqueDates,
uniqueKeys,
valueType,
xFormat: getFormatter( xFormat, d3TimeFormat ),
x2Format: getFormatter( x2Format, d3TimeFormat ),
xGroupScale: getXGroupScale( orderedKeys, xScale, compact ),
xLineScale,
xTicks,
xScale,
yMax,
yScale,
yTickOffset: getYTickOffset( adjHeight, yMax ),
yFormat: getFormatter( yFormat ),
};
getEmptyMessage() {
const { baseValue, data, emptyMessage } = this.props;
if ( emptyMessage && isDataEmpty( data, baseValue ) ) {
return (
<div className="d3-chart__empty-message">{ emptyMessage }</div>
);
}
}
render() {
const { className, data, height, type } = this.props;
const { className, data, height, orderedKeys, type } = this.props;
const computedWidth = this.getWidth();
return (
<div
className={ classNames( 'd3-chart__container', className ) }
style={ { height } }
>
{ this.getEmptyMessage() }
<div className="d3-chart__tooltip" ref={ this.tooltipRef } />
<D3Base
className={ classNames( this.props.className ) }
className={ classNames( className ) }
data={ data }
drawChart={ this.drawChart }
height={ height }
orderedKeys={ this.props.orderedKeys }
tooltipRef={ this.tooltipRef }
orderedKeys={ orderedKeys }
tooltip={ this.tooltip }
type={ type }
width={ computedWidth }
/>
@ -183,6 +185,11 @@ class D3Chart extends Component {
}
D3Chart.propTypes = {
/**
* Base chart value. If no data value is different than the baseValue, the
* `emptyMessage` will be displayed if provided.
*/
baseValue: PropTypes.number,
/**
* Additional CSS classes.
*/
@ -199,6 +206,11 @@ D3Chart.propTypes = {
* Format to parse dates into d3 time format
*/
dateParser: PropTypes.string.isRequired,
/**
* The message to be displayed if there is no data to render. If no message is provided,
* nothing will be displayed.
*/
emptyMessage: PropTypes.string,
/**
* Height of the `svg`.
*/
@ -264,6 +276,7 @@ D3Chart.propTypes = {
};
D3Chart.defaultProps = {
baseValue: 0,
data: [],
dateParser: '%Y-%m-%dT%H:%M:%S',
height: 200,

View File

@ -9,11 +9,6 @@ import { Component, createRef } from '@wordpress/element';
import { isEqual, throttle } from 'lodash';
import { select as d3Select } from 'd3-selection';
/**
* Internal dependencies
*/
import { hideTooltip } from '../utils/tooltip';
/**
* Provides foundation to use D3 within React.
*
@ -30,10 +25,6 @@ export default class D3Base extends Component {
super( props );
this.chartRef = createRef();
this.delayedScroll = throttle( () => {
hideTooltip( this.chartRef.current, props.tooltipRef.current );
}, 300 );
}
componentDidMount() {
@ -60,6 +51,13 @@ export default class D3Base extends Component {
this.deleteChart();
}
delayedScroll() {
const { tooltip } = this.props;
return throttle( () => {
tooltip && tooltip.hide();
}, 300 );
}
deleteChart() {
d3Select( this.chartRef.current )
.selectAll( 'svg' )
@ -95,8 +93,9 @@ export default class D3Base extends Component {
}
render() {
const { className } = this.props;
return (
<div className={ classNames( 'd3-base', this.props.className ) } ref={ this.chartRef } onScroll={ this.delayedScroll } />
<div className={ classNames( 'd3-base', className ) } ref={ this.chartRef } onScroll={ this.delayedScroll() } />
);
}
}
@ -105,5 +104,6 @@ D3Base.propTypes = {
className: PropTypes.string,
data: PropTypes.array,
orderedKeys: PropTypes.array, // required to detect changes in data
tooltip: PropTypes.object,
type: PropTypes.string,
};

View File

@ -61,7 +61,7 @@ class D3Legend extends Component {
} = this.props;
const { isScrollable } = this.state;
const numberOfRowsVisible = data.filter( row => row.visible ).length;
const showTotalLabel = legendDirection === 'column' && data.length > numberOfRowsVisible && totalLabel;
const showTotalLabel = legendDirection === 'column' && data.length > selectionLimit && totalLabel;
const visibleKeys = data.filter( key => key.visible );

View File

@ -57,6 +57,7 @@
align-items: center;
background-color: $white;
color: $core-grey-dark-500;
cursor: pointer;
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
@ -69,10 +70,8 @@
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
position: relative;
padding: 3px 0 3px 24px;
cursor: pointer;
font-size: 13px;
user-select: none;
width: 100%;
@ -118,6 +117,7 @@
}
.woocommerce-legend__item-total {
margin-left: auto;
font-weight: bold;
}
}
@ -138,11 +138,11 @@
}
.woocommerce-legend__direction-column & {
margin: 2px 0;
margin: 0;
padding: 0;
& > button {
height: 32px;
height: 36px;
padding: 0 17px;
}

View File

@ -10,11 +10,35 @@
.d3-chart__container {
position: relative;
width: 100%;
svg {
overflow: visible;
}
.d3-chart__empty-message {
align-items: center;
bottom: 0;
color: $core-grey-dark-300;
display: flex;
@include font-size( 18 );
font-weight: bold;
justify-content: center;
left: 0;
line-height: 1.5;
margin: 0 auto;
max-width: 50%;
padding-bottom: 48px;
position: absolute;
right: 0;
top: 0;
text-align: center;
@include breakpoint( '<782px' ) {
@include font-size( 13 );
}
}
.d3-chart__tooltip {
border: 1px solid $core-grey-light-700;
position: absolute;
@ -134,8 +158,12 @@
.focus-grid {
line {
stroke: $core-grey-light-700;
stroke: rgba( 0, 0, 0, 0.1 );
stroke-width: 1px;
}
}
.barfocus {
fill: rgba( 0, 0, 0, 0.1 );
}
}

View File

@ -22,7 +22,7 @@ const mostPoints = 31;
*/
const getFactors = inputNum => {
const numFactors = [];
for ( let i = 1; i <= Math.floor( Math.sqrt( inputNum ) ); i += 1 ) {
for ( let i = 1; i <= Math.floor( Math.sqrt( inputNum ) ); i++ ) {
if ( inputNum % i === 0 ) {
numFactors.push( i );
inputNum / i !== i && numFactors.push( inputNum / i );
@ -176,11 +176,6 @@ 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 ) {
@ -191,87 +186,90 @@ export const getYGrids = ( yMax ) => {
return yGrids;
};
export const drawAxis = ( node, params, xOffset ) => {
const xScale = params.type === 'line' ? params.xLineScale : params.xScale;
const removeDuplicateDates = ( d, i, ticks, formatter ) => {
const monthDate = moment( d ).toDate();
let prevMonth = i !== 0 ? ticks[ i - 1 ] : ticks[ i ];
prevMonth = prevMonth instanceof Date ? prevMonth : moment( prevMonth ).toDate();
return i === 0
? formatter( monthDate )
: compareStrings( formatter( prevMonth ), formatter( monthDate ) ).join( ' ' );
};
const removeDuplicateDates = ( d, i, ticks, formatter ) => {
const monthDate = moment( d ).toDate();
let prevMonth = i !== 0 ? ticks[ i - 1 ] : ticks[ i ];
prevMonth = prevMonth instanceof Date ? prevMonth : moment( prevMonth ).toDate();
return i === 0
? formatter( monthDate )
: compareStrings( formatter( prevMonth ), formatter( monthDate ) ).join( ' ' );
};
const yGrids = getYGrids( params.yMax === 0 ? 1 : params.yMax );
const ticks = params.xTicks.map( d => ( params.type === 'line' ? moment( d ).toDate() : d ) );
const drawXAxis = ( node, params, scales, formats ) => {
const height = scales.yScale.range()[ 0 ];
let ticks = getXTicks( params.uniqueDates, scales.xScale.range()[ 1 ], params.mode, params.interval );
if ( params.type === 'line' ) {
ticks = ticks.map( d => moment( d ).toDate() );
}
node
.append( 'g' )
.attr( 'class', 'axis' )
.attr( 'aria-hidden', 'true' )
.attr( 'transform', `translate(${ xOffset }, ${ params.height })` )
.attr( 'transform', `translate(0, ${ height })` )
.call(
d3AxisBottom( xScale )
d3AxisBottom( scales.xScale )
.tickValues( ticks )
.tickFormat( ( d, i ) => params.interval === 'hour'
? params.xFormat( d instanceof Date ? d : moment( d ).toDate() )
: removeDuplicateDates( d, i, ticks, params.xFormat ) )
? formats.xFormat( d instanceof Date ? d : moment( d ).toDate() )
: removeDuplicateDates( d, i, ticks, formats.xFormat ) )
);
node
.append( 'g' )
.attr( 'class', 'axis axis-month' )
.attr( 'aria-hidden', 'true' )
.attr( 'transform', `translate(${ xOffset }, ${ params.height + 20 })` )
.attr( 'transform', `translate(0, ${ height + 14 })` )
.call(
d3AxisBottom( xScale )
d3AxisBottom( scales.xScale )
.tickValues( ticks )
.tickFormat( ( d, i ) => removeDuplicateDates( d, i, ticks, params.x2Format ) )
)
.call( g => g.select( '.domain' ).remove() );
.tickFormat( ( d, i ) => removeDuplicateDates( d, i, ticks, formats.x2Format ) )
);
node
.append( 'g' )
.attr( 'class', 'pipes' )
.attr( 'transform', `translate(${ xOffset }, ${ params.height })` )
.attr( 'transform', `translate(0, ${ height })` )
.call(
d3AxisBottom( xScale )
d3AxisBottom( scales.xScale )
.tickValues( ticks )
.tickSize( 5 )
.tickFormat( '' )
);
};
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() );
const drawYAxis = ( node, scales, formats, margin ) => {
const yGrids = getYGrids( scales.yScale.domain()[ 1 ] );
const width = scales.xScale.range()[ 1 ];
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', 'grid' )
.attr( 'transform', `translate(-${ margin.left }, 0)` )
.call(
d3AxisLeft( scales.yScale )
.tickValues( yGrids )
.tickSize( -width - margin.left - margin.right )
.tickFormat( '' )
);
node
.append( 'g' )
.attr( 'class', 'axis y-axis' )
.attr( 'aria-hidden', 'true' )
.attr( 'transform', 'translate(-50, 12)' )
.attr( 'text-anchor', 'start' )
.call(
d3AxisLeft( scales.yScale )
.tickValues( scales.yMax === 0 ? [ yGrids[ 0 ] ] : yGrids )
.tickFormat( d => formats.yFormat( d !== 0 ? d : 0 ) )
);
};
export const drawAxis = ( node, params, scales, formats, margin ) => {
drawXAxis( node, params, scales, formats );
drawYAxis( node, scales, formats, margin );
node.selectAll( '.domain' ).remove();
node
.selectAll( '.axis' )
.selectAll( '.tick' )
.select( 'line' )
.remove();
node.selectAll( '.axis .tick line' ).remove();
};

View File

@ -4,23 +4,16 @@
* External dependencies
*/
import { get } from 'lodash';
import { event as d3Event, select as d3Select } from 'd3-selection';
import { event as d3Event } from 'd3-selection';
import moment from 'moment';
/**
* Internal dependencies
*/
import { getColor } from './color';
import { calculateTooltipPosition, hideTooltip, showTooltip } from './tooltip';
const handleMouseOverBarChart = ( date, parentNode, node, data, params, position ) => {
d3Select( parentNode )
.select( '.barfocus' )
.attr( 'opacity', '0.1' );
showTooltip( params, data.find( e => e.date === date ), position );
};
export const drawBars = ( node, data, params ) => {
export const drawBars = ( node, data, params, scales, formats, tooltip ) => {
const height = scales.yScale.range()[ 0 ];
const barGroup = node
.append( 'g' )
.attr( 'class', 'bars' )
@ -28,14 +21,14 @@ export const drawBars = ( node, data, params ) => {
.data( data )
.enter()
.append( 'g' )
.attr( 'transform', d => `translate(${ params.xScale( d.date ) },0)` )
.attr( 'transform', d => `translate(${ scales.xScale( d.date ) }, 0)` )
.attr( 'class', 'bargroup' )
.attr( 'role', 'region' )
.attr(
'aria-label',
d =>
params.mode === 'item-comparison'
? params.tooltipLabelFormat( d.date instanceof Date ? d.date : moment( d.date ).toDate() )
? tooltip.labelFormat( d.date instanceof Date ? d.date : moment( d.date ).toDate() )
: null
);
@ -44,23 +37,18 @@ export const drawBars = ( node, data, params ) => {
.attr( 'class', 'barfocus' )
.attr( 'x', 0 )
.attr( 'y', 0 )
.attr( 'width', params.xGroupScale.range()[ 1 ] )
.attr( 'height', params.height )
.attr( 'width', scales.xGroupScale.range()[ 1 ] )
.attr( 'height', height )
.attr( 'opacity', '0' )
.on( 'mouseover', ( d, i, nodes ) => {
const position = calculateTooltipPosition(
d3Event.target,
node.node(),
params.tooltipPosition
);
handleMouseOverBarChart( d.date, nodes[ i ].parentNode, node, data, params, position );
tooltip.show( data.find( e => e.date === d.date ), d3Event.target, nodes[ i ].parentNode );
} )
.on( 'mouseout', ( d, i, nodes ) => hideTooltip( nodes[ i ].parentNode, params.tooltip ) );
.on( 'mouseout', () => tooltip.hide() );
barGroup
.selectAll( '.bar' )
.data( d =>
params.orderedKeys.filter( row => row.visible ).map( row => ( {
params.visibleKeys.map( row => ( {
key: row.key,
focus: row.focus,
value: get( d, [ row.key, 'value' ], 0 ),
@ -72,16 +60,16 @@ export const drawBars = ( node, data, params ) => {
.enter()
.append( 'rect' )
.attr( 'class', 'bar' )
.attr( 'x', d => params.xGroupScale( d.key ) )
.attr( 'y', d => params.yScale( d.value ) )
.attr( 'width', params.xGroupScale.bandwidth() )
.attr( 'height', d => params.height - params.yScale( d.value ) )
.attr( 'x', d => scales.xGroupScale( d.key ) )
.attr( 'y', d => scales.yScale( d.value ) )
.attr( 'width', scales.xGroupScale.bandwidth() )
.attr( 'height', d => height - scales.yScale( d.value ) )
.attr( 'fill', d => getColor( d.key, params.visibleKeys, params.colorScheme ) )
.attr( 'pointer-events', 'none' )
.attr( 'tabindex', '0' )
.attr( 'aria-label', d => {
const label = params.mode === 'time-comparison' && d.label ? d.label : d.key;
return `${ label } ${ params.tooltipValueFormat( d.value ) }`;
return `${ label } ${ tooltip.valueFormat( d.value ) }`;
} )
.style( 'opacity', d => {
const opacity = d.focus ? 1 : 0.1;
@ -89,8 +77,7 @@ export const drawBars = ( node, data, params ) => {
} )
.on( 'focus', ( d, i, nodes ) => {
const targetNode = d.value > 0 ? d3Event.target : d3Event.target.parentNode;
const position = calculateTooltipPosition( targetNode, node.node(), params.tooltipPosition );
handleMouseOverBarChart( d.date, nodes[ i ].parentNode, node, data, params, position );
tooltip.show( data.find( e => e.date === d.date ), targetNode, nodes[ i ].parentNode );
} )
.on( 'blur', ( d, i, nodes ) => hideTooltip( nodes[ i ].parentNode, params.tooltip ) );
.on( 'blur', () => tooltip.hide() );
};

View File

@ -3,10 +3,9 @@
/**
* External dependencies
*/
import { find, get } from 'lodash';
import { isNil } from 'lodash';
import { format as d3Format } from 'd3-format';
import { line as d3Line } from 'd3-shape';
import moment from 'moment';
import { utcParse as d3UTCParse } from 'd3-time-format';
/**
* Allows an overriding formatter or defaults to d3Format or d3TimeFormat
@ -17,30 +16,18 @@ import moment from 'moment';
export const getFormatter = ( format, formatter = d3Format ) =>
typeof format === 'function' ? format : formatter( format );
/**
* Describes `getUniqueKeys`
* @param {array} data - The chart component's `data` prop.
* @returns {array} of unique category keys
*/
export const getUniqueKeys = data => {
return [
...new Set(
data.reduce( ( accum, curr ) => {
Object.keys( curr ).forEach( key => key !== 'date' && accum.push( key ) );
return accum;
}, [] )
),
];
};
/**
* Describes `getOrderedKeys`
* @param {array} data - The chart component's `data` prop.
* @param {array} uniqueKeys - from `getUniqueKeys`.
* @returns {array} of unique category keys ordered by cumulative total value
*/
export const getOrderedKeys = ( data, uniqueKeys ) =>
uniqueKeys
export const getOrderedKeys = ( data ) => {
const keys = new Set(
data.reduce( ( acc, curr ) => acc.concat( Object.keys( curr ) ), [] )
);
return [ ...keys ]
.filter( key => key !== 'date' )
.map( key => ( {
key,
focus: true,
@ -48,90 +35,37 @@ export const getOrderedKeys = ( data, uniqueKeys ) =>
visible: true,
} ) )
.sort( ( a, b ) => b.total - a.total );
/**
* Describes `getLineData`
* @param {array} data - The chart component's `data` prop.
* @param {array} orderedKeys - from `getOrderedKeys`.
* @returns {array} an array objects with a category `key` and an array of `values` with `date` and `value` properties
*/
export const getLineData = ( data, orderedKeys ) =>
orderedKeys.map( row => ( {
key: row.key,
focus: row.focus,
visible: row.visible,
values: data.map( d => ( {
date: d.date,
focus: row.focus,
label: get( d, [ row.key, 'label' ], '' ),
value: get( d, [ row.key, 'value' ], 0 ),
visible: row.visible,
} ) ),
} ) );
/**
* Describes `getUniqueDates`
* @param {array} lineData - from `GetLineData`
* @param {function} parseDate - D3 time format parser
* @returns {array} an array of unique date values sorted from earliest to latest
*/
export const getUniqueDates = ( lineData, parseDate ) => {
return [
...new Set(
lineData.reduce( ( accum, { values } ) => {
values.forEach( ( { date } ) => accum.push( date ) );
return accum;
}, [] )
),
].sort( ( a, b ) => parseDate( a ) - parseDate( b ) );
};
/**
* Describes getLine
* @param {function} xLineScale - from `getXLineScale`.
* @param {function} yScale - from `getYScale`.
* @returns {function} the D3 line function for plotting all category values
* Describes `getUniqueDates`
* @param {array} data - the chart component's `data` prop.
* @param {string} dateParser - D3 time format
* @returns {array} an array of unique date values sorted from earliest to latest
*/
export const getLine = ( xLineScale, yScale ) =>
d3Line()
.x( d => xLineScale( moment( d.date ).toDate() ) )
.y( d => yScale( d.value ) );
export const getUniqueDates = ( data, dateParser ) => {
const parseDate = d3UTCParse( dateParser );
const dates = new Set(
data.map( d => d.date )
);
return [ ...dates ].sort( ( a, b ) => parseDate( a ) - parseDate( b ) );
};
/**
* Describes getDateSpaces
* @param {array} data - The chart component's `data` prop.
* @param {array} uniqueDates - from `getUniqueDates`
* @param {number} width - calculated width of the charting space
* @param {function} xLineScale - from `getXLineScale`
* @returns {array} that icnludes the date, start (x position) and width to mode the mouseover rectangles
* Check whether data is empty.
* @param {array} data - the chart component's `data` prop.
* @param {number} baseValue - base value to test data values against.
* @returns {boolean} `false` if there was at least one data value different than
* the baseValue.
*/
export const getDateSpaces = ( data, uniqueDates, width, xLineScale ) =>
uniqueDates.map( ( d, i ) => {
const datapoints = find( data, { date: d } );
const xNow = xLineScale( moment( d ).toDate() );
const xPrev =
i >= 1
? xLineScale( moment( uniqueDates[ i - 1 ] ).toDate() )
: xLineScale( moment( uniqueDates[ 0 ] ).toDate() );
const xNext =
i < uniqueDates.length - 1
? xLineScale( moment( uniqueDates[ i + 1 ] ).toDate() )
: xLineScale( moment( uniqueDates[ uniqueDates.length - 1 ] ).toDate() );
let xWidth = i === 0 ? xNext - xNow : xNow - xPrev;
const xStart = i === 0 ? 0 : xNow - xWidth / 2;
xWidth = i === 0 || i === uniqueDates.length - 1 ? xWidth / 2 : xWidth;
return {
date: d,
start: uniqueDates.length > 1 ? xStart : 0,
width: uniqueDates.length > 1 ? xWidth : width,
values: Object.keys( datapoints )
.filter( key => key !== 'date' )
.map( key => {
return {
key,
value: datapoints[ key ].value,
date: d,
};
} ),
};
} );
export const isDataEmpty = ( data, baseValue = 0 ) => {
for ( let i = 0; i < data.length; i++ ) {
for ( const [ key, item ] of Object.entries( data[ i ] ) ) {
if ( key !== 'date' && ! isNil( item.value ) && item.value !== baseValue ) {
return false;
}
}
}
return true;
};

View File

@ -3,38 +3,107 @@
/**
* External dependencies
*/
import { event as d3Event, select as d3Select } from 'd3-selection';
import { smallBreak, wideBreak } from './breakpoints';
import { event as d3Event } from 'd3-selection';
import { line as d3Line } from 'd3-shape';
import moment from 'moment';
import { first, get } from 'lodash';
/**
* Internal dependencies
*/
import { getColor } from './color';
import { calculateTooltipPosition, hideTooltip, showTooltip } from './tooltip';
import { smallBreak, wideBreak } from './breakpoints';
const handleMouseOverLineChart = ( date, parentNode, node, data, params, position ) => {
d3Select( parentNode )
.select( '.focus-grid' )
.attr( 'opacity', '1' );
showTooltip( params, data.find( e => e.date === date ), position );
};
/**
* Describes getDateSpaces
* @param {array} data - The chart component's `data` prop.
* @param {array} uniqueDates - from `getUniqueDates`
* @param {number} width - calculated width of the charting space
* @param {function} xScale - from `getXLineScale`
* @returns {array} that includes the date, start (x position) and width to mode the mouseover rectangles
*/
export const getDateSpaces = ( data, uniqueDates, width, xScale ) =>
uniqueDates.map( ( d, i ) => {
const datapoints = first( data.filter( item => item.date === d ) );
const xNow = xScale( moment( d ).toDate() );
const xPrev =
i >= 1
? xScale( moment( uniqueDates[ i - 1 ] ).toDate() )
: xScale( moment( uniqueDates[ 0 ] ).toDate() );
const xNext =
i < uniqueDates.length - 1
? xScale( moment( uniqueDates[ i + 1 ] ).toDate() )
: xScale( moment( uniqueDates[ uniqueDates.length - 1 ] ).toDate() );
let xWidth = i === 0 ? xNext - xNow : xNow - xPrev;
const xStart = i === 0 ? 0 : xNow - xWidth / 2;
xWidth = i === 0 || i === uniqueDates.length - 1 ? xWidth / 2 : xWidth;
return {
date: d,
start: uniqueDates.length > 1 ? xStart : 0,
width: uniqueDates.length > 1 ? xWidth : width,
values: Object.keys( datapoints )
.filter( key => key !== 'date' )
.map( key => {
return {
key,
value: datapoints[ key ].value,
date: d,
};
} ),
};
} );
export const drawLines = ( node, data, params, xOffset ) => {
/**
* Describes getLine
* @param {function} xScale - from `getXLineScale`.
* @param {function} yScale - from `getYScale`.
* @returns {function} the D3 line function for plotting all category values
*/
export const getLine = ( xScale, yScale ) =>
d3Line()
.x( d => xScale( moment( d.date ).toDate() ) )
.y( d => yScale( d.value ) );
/**
* Describes `getLineData`
* @param {array} data - The chart component's `data` prop.
* @param {array} orderedKeys - from `getOrderedKeys`.
* @returns {array} an array objects with a category `key` and an array of `values` with `date` and `value` properties
*/
export const getLineData = ( data, orderedKeys ) =>
orderedKeys.map( row => ( {
key: row.key,
focus: row.focus,
visible: row.visible,
values: data.map( d => ( {
date: d.date,
focus: row.focus,
label: get( d, [ row.key, 'label' ], '' ),
value: get( d, [ row.key, 'value' ], 0 ),
visible: row.visible,
} ) ),
} ) );
export const drawLines = ( node, data, params, scales, formats, tooltip ) => {
const height = scales.yScale.range()[ 0 ];
const width = scales.xScale.range()[ 1 ];
const line = getLine( scales.xScale, scales.yScale );
const lineData = getLineData( data, params.visibleKeys );
const series = node
.append( 'g' )
.attr( 'class', 'lines' )
.selectAll( '.line-g' )
.data( params.lineData.filter( d => d.visible ).reverse() )
.data( lineData.filter( d => d.visible ).reverse() )
.enter()
.append( 'g' )
.attr( 'class', 'line-g' )
.attr( 'role', 'region' )
.attr( 'aria-label', d => d.key );
const dateSpaces = getDateSpaces( data, params.uniqueDates, width, scales.xScale );
let lineStroke = params.width <= wideBreak || params.uniqueDates.length > 50 ? 2 : 3;
lineStroke = params.width <= smallBreak ? 1.25 : lineStroke;
const dotRadius = params.width <= wideBreak ? 4 : 6;
let lineStroke = width <= wideBreak || params.uniqueDates.length > 50 ? 2 : 3;
lineStroke = width <= smallBreak ? 1.25 : lineStroke;
const dotRadius = width <= wideBreak ? 4 : 6;
params.uniqueDates.length > 1 &&
series
@ -48,11 +117,11 @@ export const drawLines = ( node, data, params, xOffset ) => {
const opacity = d.focus ? 1 : 0.1;
return d.visible ? opacity : 0;
} )
.attr( 'd', d => params.line( d.values ) );
.attr( 'd', d => line( d.values ) );
const minDataPointSpacing = 36;
params.width / params.uniqueDates.length > minDataPointSpacing &&
width / params.uniqueDates.length > minDataPointSpacing &&
series
.selectAll( 'circle' )
.data( ( d, i ) => d.values.map( row => ( { ...row, i, visible: d.visible, key: d.key } ) ) )
@ -66,30 +135,25 @@ export const drawLines = ( node, data, params, xOffset ) => {
const opacity = d.focus ? 1 : 0.1;
return d.visible ? opacity : 0;
} )
.attr( 'cx', d => params.xLineScale( moment( d.date ).toDate() ) + xOffset )
.attr( 'cy', d => params.yScale( d.value ) )
.attr( 'cx', d => scales.xScale( moment( d.date ).toDate() ) )
.attr( 'cy', d => scales.yScale( d.value ) )
.attr( 'tabindex', '0' )
.attr( 'aria-label', d => {
const label = d.label
? d.label
: params.tooltipLabelFormat( d.date instanceof Date ? d.date : moment( d.date ).toDate() );
return `${ label } ${ params.tooltipValueFormat( d.value ) }`;
: tooltip.labelFormat( d.date instanceof Date ? d.date : moment( d.date ).toDate() );
return `${ label } ${ tooltip.valueFormat( d.value ) }`;
} )
.on( 'focus', ( d, i, nodes ) => {
const position = calculateTooltipPosition(
d3Event.target,
node.node(),
params.tooltipPosition
);
handleMouseOverLineChart( d.date, nodes[ i ].parentNode, node, data, params, position );
tooltip.show( data.find( e => e.date === d.date ), nodes[ i ].parentNode, d3Event.target );
} )
.on( 'blur', ( d, i, nodes ) => hideTooltip( nodes[ i ].parentNode, params.tooltip ) );
.on( 'blur', () => tooltip.hide() );
const focus = node
.append( 'g' )
.attr( 'class', 'focusspaces' )
.selectAll( '.focus' )
.data( params.dateSpaces )
.data( dateSpaces )
.enter()
.append( 'g' )
.attr( 'class', 'focus' );
@ -101,10 +165,10 @@ export const drawLines = ( node, data, params, xOffset ) => {
focusGrid
.append( 'line' )
.attr( 'x1', d => params.xLineScale( moment( d.date ).toDate() ) + xOffset )
.attr( 'x1', d => scales.xScale( moment( d.date ).toDate() ) )
.attr( 'y1', 0 )
.attr( 'x2', d => params.xLineScale( moment( d.date ).toDate() ) + xOffset )
.attr( 'y2', params.height );
.attr( 'x2', d => scales.xScale( moment( d.date ).toDate() ) )
.attr( 'y2', height );
focusGrid
.selectAll( 'circle' )
@ -115,8 +179,8 @@ export const drawLines = ( node, data, params, xOffset ) => {
.attr( 'fill', d => getColor( d.key, params.visibleKeys, params.colorScheme ) )
.attr( 'stroke', '#fff' )
.attr( 'stroke-width', lineStroke + 2 )
.attr( 'cx', d => params.xLineScale( moment( d.date ).toDate() ) + xOffset )
.attr( 'cy', d => params.yScale( d.value ) );
.attr( 'cx', d => scales.xScale( moment( d.date ).toDate() ) )
.attr( 'cy', d => scales.yScale( d.value ) );
focus
.append( 'rect' )
@ -124,18 +188,12 @@ export const drawLines = ( node, data, params, xOffset ) => {
.attr( 'x', d => d.start )
.attr( 'y', 0 )
.attr( 'width', d => d.width )
.attr( 'height', params.height )
.attr( 'height', height )
.attr( 'opacity', 0 )
.on( 'mouseover', ( d, i, nodes ) => {
const isTooltipLeftAligned = ( i === 0 || i === params.dateSpaces.length - 1 ) && params.uniqueDates.length > 1;
const isTooltipLeftAligned = ( i === 0 || i === dateSpaces.length - 1 ) && params.uniqueDates.length > 1;
const elementWidthRatio = isTooltipLeftAligned ? 0 : 0.5;
const position = calculateTooltipPosition(
d3Event.target,
node.node(),
params.tooltipPosition,
elementWidthRatio
);
handleMouseOverLineChart( d.date, nodes[ i ].parentNode, node, data, params, position );
tooltip.show( data.find( e => e.date === d.date ), d3Event.target, nodes[ i ].parentNode, elementWidthRatio );
} )
.on( 'mouseout', ( d, i, nodes ) => hideTooltip( nodes[ i ].parentNode, params.tooltip ) );
.on( 'mouseout', () => tooltip.hide() );
};

View File

@ -52,13 +52,26 @@ export const getXLineScale = ( uniqueDates, width ) =>
] )
.rangeRound( [ 0, width ] );
const getMaxYValue = data => {
let maxYValue = Number.NEGATIVE_INFINITY;
data.map( d => {
for ( const [ key, item ] of Object.entries( d ) ) {
if ( key !== 'date' && Number.isFinite( item.value ) && item.value > maxYValue ) {
maxYValue = item.value;
}
}
} );
return maxYValue;
};
/**
* Describes and rounds the maximum y value to the nearest thousand, ten-thousand, million etc. In case it is a decimal number, ceils it.
* @param {array} lineData - from `getLineData`
* @param {array} data - The chart component's `data` prop.
* @returns {number} the maximum value in the timeseries multiplied by 4/3
*/
export const getYMax = lineData => {
const maxValue = Math.max( ...lineData.map( d => Math.max( ...d.values.map( date => date.value ) ) ) );
export const getYMax = data => {
const maxValue = getMaxYValue( data );
if ( ! Number.isFinite( maxValue ) || maxValue <= 0 ) {
return 0;
}
@ -70,21 +83,10 @@ export const getYMax = lineData => {
/**
* Describes getYScale
* @param {number} height - calculated height of the charting space
* @param {number} yMax - from `getYMax`
* @param {number} yMax - maximum y value
* @returns {function} the D3 linear scale from 0 to the value from `getYMax`
*/
export const getYScale = ( height, yMax ) =>
d3ScaleLinear()
.domain( [ 0, yMax === 0 ? 1 : yMax ] )
.rangeRound( [ height, 0 ] );
/**
* Describes getyTickOffset
* @param {number} height - calculated height of the charting space
* @param {number} yMax - from `getYMax`
* @returns {function} the D3 linear scale from 0 to the value from `getYMax`, offset by 12 pixels down
*/
export const getYTickOffset = ( height, yMax ) =>
d3ScaleLinear()
.domain( [ 0, yMax === 0 ? 1 : yMax ] )
.rangeRound( [ height + 12, 12 ] );

View File

@ -8,23 +8,14 @@ import { utcParse as d3UTCParse } from 'd3-time-format';
* Internal dependencies
*/
import dummyOrders from './fixtures/dummy-orders';
import orderedDates from './fixtures/dummy-ordered-dates';
import orderedKeys from './fixtures/dummy-ordered-keys';
import {
getDateSpaces,
getOrderedKeys,
getLineData,
getUniqueKeys,
getUniqueDates,
isDataEmpty,
} from '../index';
import { getXLineScale } from '../scales';
const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' );
const testUniqueKeys = getUniqueKeys( dummyOrders );
const testOrderedKeys = getOrderedKeys( dummyOrders, testUniqueKeys );
const testLineData = getLineData( dummyOrders, testOrderedKeys );
const testUniqueDates = getUniqueDates( testLineData, parseDate );
const testXLineScale = getXLineScale( testUniqueDates, 100 );
const testOrderedKeys = getOrderedKeys( dummyOrders );
describe( 'parseDate', () => {
it( 'correctly parse date in the expected format', () => {
@ -34,63 +25,68 @@ describe( 'parseDate', () => {
} );
} );
describe( 'getUniqueKeys', () => {
it( 'returns an array of keys excluding date', () => {
// sort is a mutating action so we need a copy
const testUniqueKeysClone = testUniqueKeys.slice();
const sortedAZKeys = orderedKeys.map( d => d.key ).slice();
expect( testUniqueKeysClone.sort() ).toEqual( sortedAZKeys.sort() );
} );
} );
describe( 'getOrderedKeys', () => {
it( 'returns an array of keys order by value from largest to smallest', () => {
expect( testOrderedKeys ).toEqual( orderedKeys );
} );
} );
describe( 'getLineData', () => {
it( 'returns a sorted array of objects with category key', () => {
expect( testLineData ).toBeInstanceOf( Array );
expect( testLineData ).toHaveLength( 5 );
expect( testLineData.map( d => d.key ) ).toEqual( orderedKeys.map( d => d.key ) );
describe( 'isDataEmpty', () => {
it( 'should return true when all data values are 0 and no baseValue is provided', () => {
const data = [
{
lorem: {
value: 0,
},
ipsum: {
value: 0,
},
},
];
expect( isDataEmpty( data ) ).toBeTruthy();
} );
testLineData.forEach( d => {
it( 'ensure a key and that the values property is an array', () => {
expect( d ).toHaveProperty( 'key' );
expect( d ).toHaveProperty( 'values' );
expect( d.values ).toBeInstanceOf( Array );
} );
it( 'should return true when all data values match the base value', () => {
const data = [
{
lorem: {
value: 100,
},
ipsum: {
value: 100,
},
},
];
expect( isDataEmpty( data, 100 ) ).toBeTruthy();
} );
it( 'ensure all unique dates exist in values array', () => {
const rowDates = d.values.map( row => row.date );
expect( rowDates ).toEqual( orderedDates );
} );
it( 'should return false if at least one data values doesn\'t match the base value', () => {
const data = [
{
lorem: {
value: 100,
},
ipsum: {
value: 0,
},
},
];
expect( isDataEmpty( data, 100 ) ).toBeFalsy();
} );
d.values.forEach( row => {
it( 'ensure a date property and that the values property is an array with date (parseable) and value properties', () => {
expect( row ).toHaveProperty( 'date' );
expect( row ).toHaveProperty( 'value' );
expect( parseDate( row.date ) ).not.toBeNull();
expect( typeof row.date ).toBe( 'string' );
expect( typeof row.value ).toBe( 'number' );
} );
} );
} );
} );
describe( 'getDateSpaces', () => {
it( 'return an array used to space out the mouseover rectangles, used for tooltips', () => {
const testDateSpaces = getDateSpaces( dummyOrders, testUniqueDates, 100, testXLineScale );
expect( testDateSpaces[ 0 ].date ).toEqual( '2018-05-30T00:00:00' );
expect( testDateSpaces[ 0 ].start ).toEqual( 0 );
expect( testDateSpaces[ 0 ].width ).toEqual( 10 );
expect( testDateSpaces[ 3 ].date ).toEqual( '2018-06-02T00:00:00' );
expect( testDateSpaces[ 3 ].start ).toEqual( 50 );
expect( testDateSpaces[ 3 ].width ).toEqual( 20 );
expect( testDateSpaces[ testDateSpaces.length - 1 ].date ).toEqual( '2018-06-04T00:00:00' );
expect( testDateSpaces[ testDateSpaces.length - 1 ].start ).toEqual( 90 );
expect( testDateSpaces[ testDateSpaces.length - 1 ].width ).toEqual( 10 );
it( 'should return true when all data values match the base value or are null/undefined', () => {
const data = [
{
lorem: {
value: 100,
},
ipsum: {
value: null,
},
dolor: {
},
},
];
expect( isDataEmpty( data, 100 ) ).toBeTruthy();
} );
} );

View File

@ -0,0 +1,73 @@
/** @format */
/**
* External dependencies
*/
import { utcParse as d3UTCParse } from 'd3-time-format';
/**
* Internal dependencies
*/
import dummyOrders from './fixtures/dummy-orders';
import orderedDates from './fixtures/dummy-ordered-dates';
import orderedKeys from './fixtures/dummy-ordered-keys';
import {
getOrderedKeys,
getUniqueDates,
} from '../index';
import {
getDateSpaces,
getLineData,
} from '../line-chart';
import { getXLineScale } from '../scales';
const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' );
const testOrderedKeys = getOrderedKeys( dummyOrders );
const testLineData = getLineData( dummyOrders, testOrderedKeys );
const testUniqueDates = getUniqueDates( dummyOrders, '%Y-%m-%dT%H:%M:%S' );
const testXLineScale = getXLineScale( testUniqueDates, 100 );
describe( 'getDateSpaces', () => {
it( 'return an array used to space out the mouseover rectangles, used for tooltips', () => {
const testDateSpaces = getDateSpaces( dummyOrders, testUniqueDates, 100, testXLineScale );
expect( testDateSpaces[ 0 ].date ).toEqual( '2018-05-30T00:00:00' );
expect( testDateSpaces[ 0 ].start ).toEqual( 0 );
expect( testDateSpaces[ 0 ].width ).toEqual( 10 );
expect( testDateSpaces[ 3 ].date ).toEqual( '2018-06-02T00:00:00' );
expect( testDateSpaces[ 3 ].start ).toEqual( 50 );
expect( testDateSpaces[ 3 ].width ).toEqual( 20 );
expect( testDateSpaces[ testDateSpaces.length - 1 ].date ).toEqual( '2018-06-04T00:00:00' );
expect( testDateSpaces[ testDateSpaces.length - 1 ].start ).toEqual( 90 );
expect( testDateSpaces[ testDateSpaces.length - 1 ].width ).toEqual( 10 );
} );
} );
describe( 'getLineData', () => {
it( 'returns a sorted array of objects with category key', () => {
expect( testLineData ).toBeInstanceOf( Array );
expect( testLineData ).toHaveLength( 5 );
expect( testLineData.map( d => d.key ) ).toEqual( orderedKeys.map( d => d.key ) );
} );
testLineData.forEach( d => {
it( 'ensure a key and that the values property is an array', () => {
expect( d ).toHaveProperty( 'key' );
expect( d ).toHaveProperty( 'values' );
expect( d.values ).toBeInstanceOf( Array );
} );
it( 'ensure all unique dates exist in values array', () => {
const rowDates = d.values.map( row => row.date );
expect( rowDates ).toEqual( orderedDates );
} );
d.values.forEach( row => {
it( 'ensure a date property and that the values property is an array with date (parseable) and value properties', () => {
expect( row ).toHaveProperty( 'date' );
expect( row ).toHaveProperty( 'value' );
expect( parseDate( row.date ) ).not.toBeNull();
expect( typeof row.date ).toBe( 'string' );
expect( typeof row.value ).toBe( 'number' );
} );
} );
} );
} );

View File

@ -2,7 +2,6 @@
/**
* External dependencies
*/
import { utcParse as d3UTCParse } from 'd3-time-format';
import { scaleBand, scaleLinear, scaleTime } from 'd3-scale';
/**
@ -11,11 +10,9 @@ import { scaleBand, scaleLinear, scaleTime } from 'd3-scale';
import dummyOrders from './fixtures/dummy-orders';
import {
getOrderedKeys,
getLineData,
getUniqueKeys,
getUniqueDates,
} from '../index';
import { getXGroupScale, getXScale, getXLineScale, getYMax, getYScale, getYTickOffset } from '../scales';
import { getXGroupScale, getXScale, getXLineScale, getYMax, getYScale } from '../scales';
jest.mock( 'd3-scale', () => ( {
...require.requireActual( 'd3-scale' ),
@ -37,13 +34,10 @@ jest.mock( 'd3-scale', () => ( {
} ),
} ) );
const testUniqueKeys = getUniqueKeys( dummyOrders );
const testOrderedKeys = getOrderedKeys( dummyOrders, testUniqueKeys );
const testLineData = getLineData( dummyOrders, testOrderedKeys );
const testOrderedKeys = getOrderedKeys( dummyOrders );
describe( 'X scales', () => {
const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' );
const testUniqueDates = getUniqueDates( testLineData, parseDate );
const testUniqueDates = getUniqueDates( dummyOrders, '%Y-%m-%dT%H:%M:%S' );
describe( 'getXScale', () => {
it( 'creates band scale with correct parameters', () => {
@ -96,7 +90,7 @@ describe( 'X scales', () => {
describe( 'Y scales', () => {
describe( 'getYMax', () => {
it( 'calculate the correct maximum y value', () => {
expect( getYMax( testLineData ) ).toEqual( 15000000 );
expect( getYMax( dummyOrders ) ).toEqual( 15000000 );
} );
it( 'return 0 if there is no line data', () => {
@ -120,21 +114,4 @@ describe( 'Y scales', () => {
expect( lastArgs[ 0 ] ).toBeLessThan( lastArgs[ 1 ] );
} );
} );
describe( 'getYTickOffset', () => {
it( 'creates linear scale with correct parameters', () => {
getYTickOffset( 100, 15000000 );
expect( scaleLinear().domain ).toHaveBeenLastCalledWith( [ 0, 15000000 ] );
expect( scaleLinear().rangeRound ).toHaveBeenLastCalledWith( [ 112, 12 ] );
} );
it( 'avoids the domain starting and ending at the same point when yMax is 0', () => {
getYTickOffset( 100, 0 );
const args = scaleLinear().domain.mock.calls;
const lastArgs = args[ args.length - 1 ][ 0 ];
expect( lastArgs[ 0 ] ).toBeLessThan( lastArgs[ 1 ] );
} );
} );
} );

View File

@ -11,138 +11,148 @@ import moment from 'moment';
*/
import { getColor } from './color';
export const hideTooltip = ( parentNode, tooltipNode ) => {
d3Select( parentNode )
.selectAll( '.barfocus, .focus-grid' )
.attr( 'opacity', '0' );
d3Select( tooltipNode )
.style( 'visibility', 'hidden' );
};
class ChartTooltip {
constructor() {
this.ref = null;
this.chart = null;
this.position = '';
this.title = '';
this.labelFormat = '';
this.valueFormat = '';
this.visibleKeys = '';
this.colorScheme = null;
this.margin = 24;
}
const calculateTooltipXPosition = (
elementCoords,
chartCoords,
tooltipSize,
tooltipMargin,
elementWidthRatio,
tooltipPosition
) => {
const d3BaseCoords = d3Select( '.d3-base' ).node().getBoundingClientRect();
const leftMargin = Math.max( d3BaseCoords.left, chartCoords.left );
calculateXPosition(
elementCoords,
chartCoords,
elementWidthRatio,
) {
const tooltipSize = this.ref.getBoundingClientRect();
const d3BaseCoords = d3Select( '.d3-base' ).node().getBoundingClientRect();
const leftMargin = Math.max( d3BaseCoords.left, chartCoords.left );
if ( tooltipPosition === 'below' ) {
return Math.max(
tooltipMargin,
Math.min(
elementCoords.left + elementCoords.width * 0.5 - tooltipSize.width / 2 - leftMargin,
d3BaseCoords.width - tooltipSize.width - tooltipMargin
)
if ( this.position === 'below' ) {
return Math.max(
this.margin,
Math.min(
elementCoords.left + elementCoords.width * 0.5 - tooltipSize.width / 2 - leftMargin,
d3BaseCoords.width - tooltipSize.width - this.margin
)
);
}
const xPosition =
elementCoords.left + elementCoords.width * elementWidthRatio + this.margin - leftMargin;
if ( xPosition + tooltipSize.width + this.margin > d3BaseCoords.width ) {
return Math.max(
this.margin,
elementCoords.left +
elementCoords.width * ( 1 - elementWidthRatio ) -
tooltipSize.width -
this.margin -
leftMargin
);
}
return xPosition;
}
calculateYPosition(
elementCoords,
chartCoords,
) {
if ( this.position === 'below' ) {
return chartCoords.height;
}
const tooltipSize = this.ref.getBoundingClientRect();
const yPosition = elementCoords.top + this.margin - chartCoords.top;
if ( yPosition + tooltipSize.height + this.margin > chartCoords.height ) {
return Math.max( 0, elementCoords.top - tooltipSize.height - this.margin - chartCoords.top );
}
return yPosition;
}
calculatePosition( element, elementWidthRatio = 1 ) {
const elementCoords = element.getBoundingClientRect();
const chartCoords = this.chart.getBoundingClientRect();
if ( this.position === 'below' ) {
elementWidthRatio = 0;
}
return {
x: this.calculateXPosition(
elementCoords,
chartCoords,
elementWidthRatio,
),
y: this.calculateYPosition(
elementCoords,
chartCoords,
),
};
}
hide() {
d3Select( this.chart )
.selectAll( '.barfocus, .focus-grid' )
.attr( 'opacity', '0' );
d3Select( this.ref )
.style( 'visibility', 'hidden' );
}
getTooltipRowLabel( d, row ) {
if ( d[ row.key ].labelDate ) {
return this.labelFormat( moment( d[ row.key ].labelDate ).toDate() );
}
return row.key;
}
show( d, triggerElement, parentNode, elementWidthRatio = 1 ) {
if ( ! this.visibleKeys.length ) {
return;
}
d3Select( parentNode )
.select( '.focus-grid, .barfocus' )
.attr( 'opacity', '1' );
const position = this.calculatePosition( triggerElement, elementWidthRatio );
const keys = this.visibleKeys.map(
row => `
<li class="key-row">
<div class="key-container">
<span
class="key-color"
style="background-color: ${ getColor( row.key, this.visibleKeys, this.colorScheme ) }">
</span>
<span class="key-key">${ this.getTooltipRowLabel( d, row ) }</span>
</div>
<span class="key-value">${ this.valueFormat( d[ row.key ].value ) }</span>
</li>
`
);
const tooltipTitle = this.title
? this.title
: this.labelFormat( moment( d.date ).toDate() );
d3Select( this.ref )
.style( 'left', position.x + 'px' )
.style( 'top', position.y + 'px' )
.style( 'visibility', 'visible' ).html( `
<div>
<h4>${ tooltipTitle }</h4>
<ul>
${ keys.join( '' ) }
</ul>
</div>
` );
}
}
const xPosition =
elementCoords.left + elementCoords.width * elementWidthRatio + tooltipMargin - leftMargin;
if ( xPosition + tooltipSize.width + tooltipMargin > d3BaseCoords.width ) {
return Math.max(
tooltipMargin,
elementCoords.left +
elementCoords.width * ( 1 - elementWidthRatio ) -
tooltipSize.width -
tooltipMargin -
leftMargin
);
}
return xPosition;
};
const calculateTooltipYPosition = (
elementCoords,
chartCoords,
tooltipSize,
tooltipMargin,
tooltipPosition
) => {
if ( tooltipPosition === 'below' ) {
return chartCoords.height;
}
const yPosition = elementCoords.top + tooltipMargin - chartCoords.top;
if ( yPosition + tooltipSize.height + tooltipMargin > chartCoords.height ) {
return Math.max( 0, elementCoords.top - tooltipSize.height - tooltipMargin - chartCoords.top );
}
return yPosition;
};
export const calculateTooltipPosition = ( element, chart, tooltipPosition, elementWidthRatio = 1 ) => {
const elementCoords = element.getBoundingClientRect();
const chartCoords = chart.getBoundingClientRect();
const tooltipSize = d3Select( '.d3-chart__tooltip' )
.node()
.getBoundingClientRect();
const tooltipMargin = 24;
if ( tooltipPosition === 'below' ) {
elementWidthRatio = 0;
}
return {
x: calculateTooltipXPosition(
elementCoords,
chartCoords,
tooltipSize,
tooltipMargin,
elementWidthRatio,
tooltipPosition
),
y: calculateTooltipYPosition(
elementCoords,
chartCoords,
tooltipSize,
tooltipMargin,
tooltipPosition
),
};
};
const getTooltipRowLabel = ( d, row, params ) => {
if ( d[ row.key ].labelDate ) {
return params.tooltipLabelFormat( moment( d[ row.key ].labelDate ).toDate() );
}
return row.key;
};
export const showTooltip = ( params, d, position ) => {
const keys = params.visibleKeys.map(
row => `
<li class="key-row">
<div class="key-container">
<span
class="key-color"
style="background-color:${ getColor( row.key, params.visibleKeys, params.colorScheme ) }">
</span>
<span class="key-key">${ getTooltipRowLabel( d, row, params ) }</span>
</div>
<span class="key-value">${ params.tooltipValueFormat( d[ row.key ].value ) }</span>
</li>
`
);
const tooltipTitle = params.tooltipTitle
? params.tooltipTitle
: params.tooltipLabelFormat( moment( d.date ).toDate() );
d3Select( params.tooltip )
.style( 'left', position.x + 'px' )
.style( 'top', position.y + 'px' )
.style( 'visibility', 'visible' ).html( `
<div>
<h4>${ tooltipTitle }</h4>
<ul>
${ keys.join( '' ) }
</ul>
</div>
` );
};
export default ChartTooltip;

View File

@ -266,7 +266,9 @@ class Chart extends Component {
render() {
const { interactiveLegend, orderedKeys, visibleData, width } = this.state;
const {
baseValue,
dateParser,
emptyMessage,
interval,
isRequesting,
isViewportLarge,
@ -376,10 +378,12 @@ class Chart extends Component {
{ ! isRequesting &&
width > 0 && (
<D3Chart
baseValue={ baseValue }
colorScheme={ d3InterpolateViridis }
data={ visibleData }
dateParser={ dateParser }
height={ chartHeight }
emptyMessage={ emptyMessage }
interval={ interval }
margin={ margin }
mode={ mode }
@ -411,6 +415,11 @@ Chart.propTypes = {
* Allowed intervals to show in a dropdown.
*/
allowedIntervals: PropTypes.array,
/**
* Base chart value. If no data value is different than the baseValue, the
* `emptyMessage` will be displayed if provided.
*/
baseValue: PropTypes.number,
/**
* An array of data.
*/
@ -419,6 +428,11 @@ Chart.propTypes = {
* Format to parse dates into d3 time format
*/
dateParser: PropTypes.string.isRequired,
/**
* The message to be displayed if there is no data to render. If no message is provided,
* nothing will be displayed.
*/
emptyMessage: PropTypes.string,
/**
* Label describing the legend items.
*/
@ -500,6 +514,7 @@ Chart.propTypes = {
};
Chart.defaultProps = {
baseValue: 0,
data: [],
dateParser: '%Y-%m-%dT%H:%M:%S',
interactiveLegend: true,

View File

@ -186,16 +186,17 @@ class DateFilter extends Component {
}
render() {
const { config, filter, isEnglish } = this.props;
const { className, config, filter, isEnglish } = this.props;
const { rule } = filter;
const { labels, rules } = config;
const screenReaderText = this.getScreenReaderText( filter, config );
const children = interpolateComponents( {
mixedString: labels.title,
components: {
title: <span className={ className } />,
rule: (
<SelectControl
className="woocommerce-filters-advanced__rule"
className={ classnames( className, 'woocommerce-filters-advanced__rule' ) }
options={ rules }
value={ rule }
onChange={ this.onRuleChange }
@ -204,7 +205,7 @@ class DateFilter extends Component {
),
filter: (
<div
className={ classnames( 'woocommerce-filters-advanced__input-range', {
className={ classnames( className, 'woocommerce-filters-advanced__input-range', {
'is-between': 'between' === rule,
} ) }
>
@ -215,7 +216,7 @@ class DateFilter extends Component {
} );
/*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset tabIndex="0">
<fieldset className="woocommerce-filters-advanced__line-item" tabIndex="0">
<legend className="screen-reader-text">{ labels.add || '' }</legend>
<div
className={ classnames( 'woocommerce-filters-advanced__fieldset', {

View File

@ -9,6 +9,7 @@ import { partial, findIndex, difference, isEqual } from 'lodash';
import PropTypes from 'prop-types';
import Gridicon from 'gridicons';
import interpolateComponents from 'interpolate-components';
import classnames from 'classnames';
/**
* WooCommerce dependencies
@ -190,6 +191,7 @@ class AdvancedFilters extends Component {
<li className="woocommerce-filters-advanced__list-item" key={ key }>
{ 'SelectControl' === input.component && (
<SelectFilter
className="woocommerce-filters-advanced__fieldset-item"
filter={ filter }
config={ config.filters[ key ] }
onFilterChange={ this.onFilterChange }
@ -198,6 +200,7 @@ class AdvancedFilters extends Component {
) }
{ 'Search' === input.component && (
<SearchFilter
className="woocommerce-filters-advanced__fieldset-item"
filter={ filter }
config={ config.filters[ key ] }
onFilterChange={ this.onFilterChange }
@ -207,6 +210,7 @@ class AdvancedFilters extends Component {
) }
{ 'Number' === input.component && (
<NumberFilter
className="woocommerce-filters-advanced__fieldset-item"
filter={ filter }
config={ config.filters[ key ] }
onFilterChange={ this.onFilterChange }
@ -216,6 +220,7 @@ class AdvancedFilters extends Component {
) }
{ 'Currency' === input.component && (
<NumberFilter
className="woocommerce-filters-advanced__fieldset-item"
filter={ filter }
config={ { ...config.filters[ key ], ...{ input: { type: 'currency', component: 'Currency' } } } }
onFilterChange={ this.onFilterChange }
@ -225,6 +230,7 @@ class AdvancedFilters extends Component {
) }
{ 'Date' === input.component && (
<DateFilter
className="woocommerce-filters-advanced__fieldset-item"
filter={ filter }
config={ config.filters[ key ] }
onFilterChange={ this.onFilterChange }
@ -234,7 +240,10 @@ class AdvancedFilters extends Component {
/>
) }
<IconButton
className="woocommerce-filters-advanced__remove"
className={ classnames(
'woocommerce-filters-advanced__line-item',
'woocommerce-filters-advanced__remove'
) }
label={ labels.remove }
onClick={ partial( this.removeFilter, key ) }
icon={ <Gridicon icon="cross-small" /> }

View File

@ -185,16 +185,17 @@ class NumberFilter extends Component {
}
render() {
const { config, filter, onFilterChange, isEnglish } = this.props;
const { className, config, filter, onFilterChange, isEnglish } = this.props;
const { key, rule } = filter;
const { labels, rules } = config;
const children = interpolateComponents( {
mixedString: labels.title,
components: {
title: <span className={ className } />,
rule: (
<SelectControl
className="woocommerce-filters-advanced__rule"
className={ classnames( className, 'woocommerce-filters-advanced__rule' ) }
options={ rules }
value={ rule }
onChange={ partial( onFilterChange, key, 'rule' ) }
@ -203,7 +204,7 @@ class NumberFilter extends Component {
),
filter: (
<div
className={ classnames( 'woocommerce-filters-advanced__input-range', {
className={ classnames( className, 'woocommerce-filters-advanced__input-range', {
'is-between': 'between' === rule,
} ) }
>
@ -217,7 +218,7 @@ class NumberFilter extends Component {
/*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset tabIndex="0">
<fieldset className="woocommerce-filters-advanced__line-item" tabIndex="0">
<legend className="screen-reader-text">
{ labels.add || '' }
</legend>

View File

@ -40,7 +40,12 @@ class SearchFilter extends Component {
}
updateLabels( selected ) {
this.setState( { selected } );
const prevIds = this.state.selected.map( item => item.id );
const ids = selected.map( item => item.id );
if ( ! isEqual( ids.sort(), prevIds.sort() ) ) {
this.setState( { selected } );
}
}
onSearchChange( values ) {
@ -72,16 +77,17 @@ class SearchFilter extends Component {
}
render() {
const { config, filter, onFilterChange, isEnglish } = this.props;
const { className, config, filter, onFilterChange, isEnglish } = this.props;
const { selected } = this.state;
const { key, rule } = filter;
const { input, labels, rules } = config;
const children = interpolateComponents( {
mixedString: labels.title,
components: {
title: <span className={ className } />,
rule: (
<SelectControl
className="woocommerce-filters-advanced__rule"
className={ classnames( className, 'woocommerce-filters-advanced__rule' ) }
options={ rules }
value={ rule }
onChange={ partial( onFilterChange, key, 'rule' ) }
@ -90,7 +96,7 @@ class SearchFilter extends Component {
),
filter: (
<Search
className="woocommerce-filters-advanced__input"
className={ classnames( className, 'woocommerce-filters-advanced__input' ) }
onChange={ this.onSearchChange }
type={ input.type }
placeholder={ labels.placeholder }
@ -106,7 +112,7 @@ class SearchFilter extends Component {
/*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset tabIndex="0">
<fieldset className="woocommerce-filters-advanced__line-item" tabIndex="0">
<legend className="screen-reader-text">
{ labels.add || '' }
</legend>

View File

@ -64,16 +64,17 @@ class SelectFilter extends Component {
}
render() {
const { config, filter, onFilterChange, isEnglish } = this.props;
const { className, config, filter, onFilterChange, isEnglish } = this.props;
const { options } = this.state;
const { key, rule, value } = filter;
const { labels, rules } = config;
const children = interpolateComponents( {
mixedString: labels.title,
components: {
title: <span className={ className } />,
rule: (
<SelectControl
className="woocommerce-filters-advanced__rule"
className={ classnames( className, 'woocommerce-filters-advanced__rule' ) }
options={ rules }
value={ rule }
onChange={ partial( onFilterChange, key, 'rule' ) }
@ -82,7 +83,7 @@ class SelectFilter extends Component {
),
filter: options ? (
<SelectControl
className="woocommerce-filters-advanced__input"
className={ classnames( className, 'woocommerce-filters-advanced__input' ) }
options={ options }
value={ value }
onChange={ partial( onFilterChange, filter.key, 'value' ) }
@ -98,7 +99,7 @@ class SelectFilter extends Component {
/*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
return (
<fieldset tabIndex="0">
<fieldset className="woocommerce-filters-advanced__line-item" tabIndex="0">
<legend className="screen-reader-text">
{ labels.add || '' }
</legend>

View File

@ -45,18 +45,22 @@
padding: 0 $gap 0 0;
margin: 0;
display: grid;
grid-template-columns: auto 40px;
grid-template-columns: 1fr 40px;
background-color: $core-grey-light-100;
border-bottom: 1px solid $core-grey-light-700;
fieldset {
padding: $gap-smaller $gap-smaller $gap-smaller $gap;
}
&:hover {
background-color: $core-grey-light-200;
}
.woocommerce-filters-advanced__line-item {
@include set-grid-item-position( 2, 2 );
}
fieldset {
padding: $gap-smaller $gap-smaller $gap-smaller $gap;
}
.woocommerce-filters-advanced__remove {
width: 40px;
height: 38px;
@ -125,7 +129,16 @@
&.is-english {
display: grid;
grid-template-columns: 100px 150px auto;
grid-template-columns: 100px 150px 1fr;
.woocommerce-filters-advanced__fieldset-item {
@include set-grid-item-position( 3, 3 );
&:nth-child(1) {
display: flex;
align-items: center;
}
}
@include breakpoint( '<782px' ) {
display: block;
@ -151,6 +164,7 @@
svg {
fill: currentColor;
margin: 0 6px 0 0;
}
&.components-icon-button:not(:disabled):not([aria-disabled='true']):not(.is-default):hover {

View File

@ -59,7 +59,7 @@ export class Autocomplete extends Component {
this.reset = this.reset.bind( this );
this.search = this.search.bind( this );
this.handleKeyDown = this.handleKeyDown.bind( this );
this.debouncedLoadOptions = debounce( this.loadOptions, 250 );
this.debouncedLoadOptions = debounce( this.loadOptions, 400 );
this.state = this.constructor.getInitialState();
}

View File

@ -20,6 +20,7 @@ import Flag from '../../flag';
export default {
name: 'countries',
className: 'woocommerce-search__country-result',
isDebounced: true,
options() {
return wcSettings.dataEndpoints.countries || [];
},

View File

@ -16,8 +16,6 @@ import { stringifyQuery } from '@woocommerce/navigation';
*/
import { computeSuggestionMatch } from './utils';
const getName = customer => [ customer.first_name, customer.last_name ].filter( Boolean ).join( ' ' );
/**
* A customer completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
@ -40,7 +38,7 @@ export default {
},
isDebounced: true,
getOptionKeywords( customer ) {
return [ getName( customer ) ];
return [ customer.name ];
},
getFreeTextOptions( query ) {
const label = (
@ -56,15 +54,15 @@ export default {
const nameOption = {
key: 'name',
label: label,
value: { id: query, first_name: query },
value: { id: query, name: query },
};
return [ nameOption ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( getName( customer ), query ) || {};
const match = computeSuggestionMatch( customer.name, query ) || {};
return [
<span key="name" className="woocommerce-search__result-name" aria-label={ getName( customer ) }>
<span key="name" className="woocommerce-search__result-name" aria-label={ customer.name }>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
@ -78,7 +76,7 @@ export default {
getOptionCompletion( customer ) {
return {
id: customer.id,
label: getName( customer ),
label: customer.name,
};
},
};

Some files were not shown because too many files have changed in this diff Show More