Merge branch 'master' into fix/489-date_date_gmt
# Conflicts: # includes/class-wc-admin-api-init.php
This commit is contained in:
commit
f7d0d2379f
|
@ -26,6 +26,7 @@ wp-cli.local.yml
|
|||
yarn.lock
|
||||
tests
|
||||
vendor
|
||||
config
|
||||
node_modules
|
||||
*.sql
|
||||
*.tar.gz
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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! 🎉 "
|
||||
|
|
|
@ -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 );
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 );
|
||||
|
||||
|
|
|
@ -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 )
|
||||
: {};
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
} )
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 ] }
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 );
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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,
|
||||
},
|
||||
} );
|
|
@ -0,0 +1,9 @@
|
|||
/** @format */
|
||||
|
||||
const addNotice = notice => {
|
||||
return { type: 'ADD_NOTICE', notice };
|
||||
};
|
||||
|
||||
export default {
|
||||
addNotice,
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
/** @format */
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import reducers from './reducers';
|
||||
import actions from './actions';
|
||||
import selectors from './selectors';
|
||||
|
||||
export default {
|
||||
reducers,
|
||||
actions,
|
||||
selectors,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/** @format */
|
||||
|
||||
const getNotices = state => {
|
||||
return state.notices;
|
||||
};
|
||||
|
||||
export default {
|
||||
getNotices,
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
/** @format */
|
||||
|
||||
export const DEFAULT_STATE = {
|
||||
notices: [],
|
||||
};
|
||||
|
||||
export const testNotice = { message: 'Test notice' };
|
|
@ -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 ] );
|
||||
} );
|
||||
} );
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 );
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"features": {
|
||||
"activity-panels": false,
|
||||
"analytics": false,
|
||||
"dashboard": false,
|
||||
"devdocs": false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"features": {
|
||||
"activity-panels": true,
|
||||
"analytics": true,
|
||||
"dashboard": true,
|
||||
"devdocs": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"features": {
|
||||
"activity-panels": false,
|
||||
"analytics": true,
|
||||
"dashboard": true,
|
||||
"devdocs": false
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
* [Overview](/)
|
||||
* [Components](components/)
|
||||
* [Feature Flags](feature-flags)
|
||||
* [Data](data)
|
||||
* [Documentation](documentation)
|
||||
* [Layout](layout)
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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' );
|
||||
}
|
||||
```
|
|
@ -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 );
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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().
|
||||
|
|
|
@ -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();
|
|
@ -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.
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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' );
|
|
@ -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>';
|
||||
|
|
|
@ -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 ];
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 );
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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() );
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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() );
|
||||
};
|
||||
|
|
|
@ -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 ] );
|
||||
|
|
|
@ -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();
|
||||
} );
|
||||
} );
|
||||
|
|
73
plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/line-chart.js
vendored
Normal file
73
plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/line-chart.js
vendored
Normal 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' );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -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 ] );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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" /> }
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import Flag from '../../flag';
|
|||
export default {
|
||||
name: 'countries',
|
||||
className: 'woocommerce-search__country-result',
|
||||
isDebounced: true,
|
||||
options() {
|
||||
return wcSettings.dataEndpoints.countries || [];
|
||||
},
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue