diff --git a/plugins/woocommerce-admin/.distignore b/plugins/woocommerce-admin/.distignore index 962c3e3791d..474a2ccbdad 100755 --- a/plugins/woocommerce-admin/.distignore +++ b/plugins/woocommerce-admin/.distignore @@ -26,6 +26,7 @@ wp-cli.local.yml yarn.lock tests vendor +config node_modules *.sql *.tar.gz diff --git a/plugins/woocommerce-admin/.gitignore b/plugins/woocommerce-admin/.gitignore index 6e95a92ce83..d60017a0d8a 100755 --- a/plugins/woocommerce-admin/.gitignore +++ b/plugins/woocommerce-admin/.gitignore @@ -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 diff --git a/plugins/woocommerce-admin/bin/build-plugin-zip.sh b/plugins/woocommerce-admin/bin/build-plugin-zip.sh index aab03d165aa..27dffc08744 100755 --- a/plugins/woocommerce-admin/bin/build-plugin-zip.sh +++ b/plugins/woocommerce-admin/bin/build-plugin-zip.sh @@ -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! 🎉 " diff --git a/plugins/woocommerce-admin/bin/generate-feature-config.php b/plugins/woocommerce-admin/bin/generate-feature-config.php new file mode 100644 index 00000000000..394e4e08322 --- /dev/null +++ b/plugins/woocommerce-admin/bin/generate-feature-config.php @@ -0,0 +1,28 @@ +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 ); diff --git a/plugins/woocommerce-admin/client/analytics/components/report-chart/index.js b/plugins/woocommerce-admin/client/analytics/components/report-chart/index.js index e73b07690a3..f356fee7248 100644 --- a/plugins/woocommerce-admin/client/analytics/components/report-chart/index.js +++ b/plugins/woocommerce-admin/client/analytics/components/report-chart/index.js @@ -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 ( 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 ); diff --git a/plugins/woocommerce-admin/client/analytics/components/report-table/index.js b/plugins/woocommerce-admin/client/analytics/components/report-table/index.js index 6af282386be..d44f1c9d808 100644 --- a/plugins/woocommerce-admin/client/analytics/components/report-table/index.js +++ b/plugins/woocommerce-admin/client/analytics/components/report-table/index.js @@ -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 ) : {}; diff --git a/plugins/woocommerce-admin/client/analytics/report/categories/config.js b/plugins/woocommerce-admin/client/analytics/report/categories/config.js index c617689e71c..ecc71e6b3fd 100644 --- a/plugins/woocommerce-admin/client/analytics/report/categories/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/categories/config.js @@ -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', }, ]; diff --git a/plugins/woocommerce-admin/client/analytics/report/categories/index.js b/plugins/woocommerce-admin/client/analytics/report/categories/index.js index 8921a31c373..b71505d89a5 100644 --- a/plugins/woocommerce-admin/client/analytics/report/categories/index.js +++ b/plugins/woocommerce-admin/client/analytics/report/categories/index.js @@ -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 ( @@ -47,4 +78,5 @@ export default class CategoriesReport extends Component { CategoriesReport.propTypes = { query: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, }; diff --git a/plugins/woocommerce-admin/client/analytics/report/coupons/config.js b/plugins/woocommerce-admin/client/analytics/report/coupons/config.js index 066feeb3f09..1c65a265419 100644 --- a/plugins/woocommerce-admin/client/analytics/report/coupons/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/coupons/config.js @@ -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', }, ]; diff --git a/plugins/woocommerce-admin/client/analytics/report/customers/config.js b/plugins/woocommerce-admin/client/analytics/report/customers/config.js index fe8bdc82b42..7c717433ac4 100644 --- a/plugins/woocommerce-admin/client/analytics/report/customers/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/customers/config.js @@ -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: [ diff --git a/plugins/woocommerce-admin/client/analytics/report/downloads/config.js b/plugins/woocommerce-admin/client/analytics/report/downloads/config.js index 1ff8b49a39a..c510cf0aa65 100644 --- a/plugins/woocommerce-admin/client/analytics/report/downloads/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/downloads/config.js @@ -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: [ diff --git a/plugins/woocommerce-admin/client/analytics/report/index.js b/plugins/woocommerce-admin/client/analytics/report/index.js index c9078b5269a..b9d5230eb33 100644 --- a/plugins/woocommerce-admin/client/analytics/report/index.js +++ b/plugins/woocommerce-admin/client/analytics/report/index.js @@ -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 { diff --git a/plugins/woocommerce-admin/client/analytics/report/orders/config.js b/plugins/woocommerce-admin/client/analytics/report/orders/config.js index 5712c0b4cb7..4556f8e09a1 100644 --- a/plugins/woocommerce-admin/client/analytics/report/orders/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/orders/config.js @@ -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: { diff --git a/plugins/woocommerce-admin/client/analytics/report/orders/table.js b/plugins/woocommerce-admin/client/analytics/report/orders/table.js index 0885bafc28a..80d1acf4d48 100644 --- a/plugins/woocommerce-admin/client/analytics/report/orders/table.js +++ b/plugins/woocommerce-admin/client/analytics/report/orders/table.js @@ -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, }, ]; diff --git a/plugins/woocommerce-admin/client/analytics/report/products/config.js b/plugins/woocommerce-admin/client/analytics/report/products/config.js index 914da174d96..db181844392 100644 --- a/plugins/woocommerce-admin/client/analytics/report/products/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/products/config.js @@ -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', }, ]; diff --git a/plugins/woocommerce-admin/client/analytics/report/products/index.js b/plugins/woocommerce-admin/client/analytics/report/products/index.js index 2f787f6d189..42e6ec2b141 100644 --- a/plugins/woocommerce-admin/client/analytics/report/products/index.js +++ b/plugins/woocommerce-admin/client/analytics/report/products/index.js @@ -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, diff --git a/plugins/woocommerce-admin/client/analytics/report/revenue/config.js b/plugins/woocommerce-admin/client/analytics/report/revenue/config.js index d5288929b14..899682fe734 100644 --- a/plugins/woocommerce-admin/client/analytics/report/revenue/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/revenue/config.js @@ -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', }, ]; diff --git a/plugins/woocommerce-admin/client/analytics/report/revenue/table.js b/plugins/woocommerce-admin/client/analytics/report/revenue/table.js index e202237ebb0..4721e9d489d 100644 --- a/plugins/woocommerce-admin/client/analytics/report/revenue/table.js +++ b/plugins/woocommerce-admin/client/analytics/report/revenue/table.js @@ -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, diff --git a/plugins/woocommerce-admin/client/analytics/report/stock/config.js b/plugins/woocommerce-admin/client/analytics/report/stock/config.js index 43d01296c88..9760b9e4d40 100644 --- a/plugins/woocommerce-admin/client/analytics/report/stock/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/stock/config.js @@ -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' }, ], }, ]; diff --git a/plugins/woocommerce-admin/client/analytics/report/stock/table.js b/plugins/woocommerce-admin/client/analytics/report/stock/table.js index fff176fe4c3..ce86e301839 100644 --- a/plugins/woocommerce-admin/client/analytics/report/stock/table.js +++ b/plugins/woocommerce-admin/client/analytics/report/stock/table.js @@ -76,7 +76,7 @@ export default class StockReportTable extends Component { ); const stockStatusLink = ( - + { stockStatuses[ stock_status ] } ); @@ -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', diff --git a/plugins/woocommerce-admin/client/analytics/report/taxes/config.js b/plugins/woocommerce-admin/client/analytics/report/taxes/config.js index b9a978a6179..e240f493355 100644 --- a/plugins/woocommerce-admin/client/analytics/report/taxes/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/taxes/config.js @@ -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', }, ]; diff --git a/plugins/woocommerce-admin/client/analytics/settings/index.js b/plugins/woocommerce-admin/client/analytics/settings/index.js index 54722e5652e..a26311710b0 100644 --- a/plugins/woocommerce-admin/client/analytics/settings/index.js +++ b/plugins/woocommerce-admin/client/analytics/settings/index.js @@ -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, }; } ) diff --git a/plugins/woocommerce-admin/client/embedded.js b/plugins/woocommerce-admin/client/embedded.js index 4cd027349aa..230e6548dcc 100644 --- a/plugins/woocommerce-admin/client/embedded.js +++ b/plugins/woocommerce-admin/client/embedded.js @@ -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( diff --git a/plugins/woocommerce-admin/client/header/index.js b/plugins/woocommerce-admin/client/header/index.js index c8a9353f8f2..e0298532bd3 100644 --- a/plugins/woocommerce-admin/client/header/index.js +++ b/plugins/woocommerce-admin/client/header/index.js @@ -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 ) ? ( { section[ 1 ] } diff --git a/plugins/woocommerce-admin/client/index.js b/plugins/woocommerce-admin/client/index.js index 2bd3841f7af..f57ca37af21 100644 --- a/plugins/woocommerce-admin/client/index.js +++ b/plugins/woocommerce-admin/client/index.js @@ -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( diff --git a/plugins/woocommerce-admin/client/layout/controller.js b/plugins/woocommerce-admin/client/layout/controller.js index f64cc1d47a7..ad2bd43110a 100644 --- a/plugins/woocommerce-admin/client/layout/controller.js +++ b/plugins/woocommerce-admin/client/layout/controller.js @@ -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; }; diff --git a/plugins/woocommerce-admin/client/layout/index.js b/plugins/woocommerce-admin/client/layout/index.js index da410fac301..47e5eae5b66 100644 --- a/plugins/woocommerce-admin/client/layout/index.js +++ b/plugins/woocommerce-admin/client/layout/index.js @@ -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 (
- + { window.wcAdminFeatures[ 'activity-panels' ] && } +
diff --git a/plugins/woocommerce-admin/client/layout/style.scss b/plugins/woocommerce-admin/client/layout/style.scss index 0197b3b0fc0..4efb8135616 100644 --- a/plugins/woocommerce-admin/client/layout/style.scss +++ b/plugins/woocommerce-admin/client/layout/style.scss @@ -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; diff --git a/plugins/woocommerce-admin/client/layout/transient-notices/index.js b/plugins/woocommerce-admin/client/layout/transient-notices/index.js new file mode 100644 index 00000000000..454096cc4ad --- /dev/null +++ b/plugins/woocommerce-admin/client/layout/transient-notices/index.js @@ -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 ( +
+ { notices && notices.map( ( notice, i ) => ) } +
+ ); + } +} + +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 ); diff --git a/plugins/woocommerce-admin/client/layout/transient-notices/style.scss b/plugins/woocommerce-admin/client/layout/transient-notices/style.scss new file mode 100644 index 00000000000..57a2826db30 --- /dev/null +++ b/plugins/woocommerce-admin/client/layout/transient-notices/style.scss @@ -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); + } +} diff --git a/plugins/woocommerce-admin/client/layout/transient-notices/transient-notice.js b/plugins/woocommerce-admin/client/layout/transient-notices/transient-notice.js new file mode 100644 index 00000000000..d74ceb6a598 --- /dev/null +++ b/plugins/woocommerce-admin/client/layout/transient-notices/transient-notice.js @@ -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 ( + +
+ + { message } + +
+
+ ); + } +} + +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; diff --git a/plugins/woocommerce-admin/client/store/index.js b/plugins/woocommerce-admin/client/store/index.js new file mode 100644 index 00000000000..1e9e141e1f8 --- /dev/null +++ b/plugins/woocommerce-admin/client/store/index.js @@ -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, + }, +} ); diff --git a/plugins/woocommerce-admin/client/store/notices/actions.js b/plugins/woocommerce-admin/client/store/notices/actions.js new file mode 100644 index 00000000000..24065c7ecc2 --- /dev/null +++ b/plugins/woocommerce-admin/client/store/notices/actions.js @@ -0,0 +1,9 @@ +/** @format */ + +const addNotice = notice => { + return { type: 'ADD_NOTICE', notice }; +}; + +export default { + addNotice, +}; diff --git a/plugins/woocommerce-admin/client/store/notices/index.js b/plugins/woocommerce-admin/client/store/notices/index.js new file mode 100644 index 00000000000..10e658b90ed --- /dev/null +++ b/plugins/woocommerce-admin/client/store/notices/index.js @@ -0,0 +1,13 @@ +/** @format */ +/** + * Internal dependencies + */ +import reducers from './reducers'; +import actions from './actions'; +import selectors from './selectors'; + +export default { + reducers, + actions, + selectors, +}; diff --git a/plugins/woocommerce-admin/client/store/notices/reducers.js b/plugins/woocommerce-admin/client/store/notices/reducers.js new file mode 100644 index 00000000000..f0a2634160d --- /dev/null +++ b/plugins/woocommerce-admin/client/store/notices/reducers.js @@ -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, +}; diff --git a/plugins/woocommerce-admin/client/store/notices/selectors.js b/plugins/woocommerce-admin/client/store/notices/selectors.js new file mode 100644 index 00000000000..5b805ba1109 --- /dev/null +++ b/plugins/woocommerce-admin/client/store/notices/selectors.js @@ -0,0 +1,9 @@ +/** @format */ + +const getNotices = state => { + return state.notices; +}; + +export default { + getNotices, +}; diff --git a/plugins/woocommerce-admin/client/store/notices/test/fixtures/index.js b/plugins/woocommerce-admin/client/store/notices/test/fixtures/index.js new file mode 100644 index 00000000000..4f5e29c4190 --- /dev/null +++ b/plugins/woocommerce-admin/client/store/notices/test/fixtures/index.js @@ -0,0 +1,7 @@ +/** @format */ + +export const DEFAULT_STATE = { + notices: [], +}; + +export const testNotice = { message: 'Test notice' }; diff --git a/plugins/woocommerce-admin/client/store/notices/test/index.js b/plugins/woocommerce-admin/client/store/notices/test/index.js new file mode 100644 index 00000000000..e495b015751 --- /dev/null +++ b/plugins/woocommerce-admin/client/store/notices/test/index.js @@ -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 ] ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss b/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss index 754121d1873..920882f2546 100644 --- a/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss +++ b/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss @@ -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; + } + } } } diff --git a/plugins/woocommerce-admin/client/wc-api/constants.js b/plugins/woocommerce-admin/client/wc-api/constants.js index 91513917478..37ca42d5bc4 100644 --- a/plugins/woocommerce-admin/client/wc-api/constants.js +++ b/plugins/woocommerce-admin/client/wc-api/constants.js @@ -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 diff --git a/plugins/woocommerce-admin/client/wc-api/reports/stats/operations.js b/plugins/woocommerce-admin/client/wc-api/reports/stats/operations.js index 3ff20c03312..49f5c61c10c 100644 --- a/plugins/woocommerce-admin/client/wc-api/reports/stats/operations.js +++ b/plugins/woocommerce-admin/client/wc-api/reports/stats/operations.js @@ -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 ); diff --git a/plugins/woocommerce-admin/client/wc-api/reports/utils.js b/plugins/woocommerce-admin/client/wc-api/reports/utils.js index da380a51a19..23a010a2169 100644 --- a/plugins/woocommerce-admin/client/wc-api/reports/utils.js +++ b/plugins/woocommerce-admin/client/wc-api/reports/utils.js @@ -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 }; } diff --git a/plugins/woocommerce-admin/client/wc-api/settings/operations.js b/plugins/woocommerce-admin/client/wc-api/settings/operations.js index ada55a54fb2..d54f2f4780c 100644 --- a/plugins/woocommerce-admin/client/wc-api/settings/operations.js +++ b/plugins/woocommerce-admin/client/wc-api/settings/operations.js @@ -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, diff --git a/plugins/woocommerce-admin/client/wc-api/settings/selectors.js b/plugins/woocommerce-admin/client/wc-api/settings/selectors.js index d586d90958e..ceb64e23f44 100644 --- a/plugins/woocommerce-admin/client/wc-api/settings/selectors.js +++ b/plugins/woocommerce-admin/client/wc-api/settings/selectors.js @@ -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, }; diff --git a/plugins/woocommerce-admin/config/core.json b/plugins/woocommerce-admin/config/core.json new file mode 100644 index 00000000000..08376bb4341 --- /dev/null +++ b/plugins/woocommerce-admin/config/core.json @@ -0,0 +1,8 @@ +{ + "features": { + "activity-panels": false, + "analytics": false, + "dashboard": false, + "devdocs": false + } +} \ No newline at end of file diff --git a/plugins/woocommerce-admin/config/development.json b/plugins/woocommerce-admin/config/development.json new file mode 100644 index 00000000000..30f91143f9a --- /dev/null +++ b/plugins/woocommerce-admin/config/development.json @@ -0,0 +1,8 @@ +{ + "features": { + "activity-panels": true, + "analytics": true, + "dashboard": true, + "devdocs": true + } +} \ No newline at end of file diff --git a/plugins/woocommerce-admin/config/plugin.json b/plugins/woocommerce-admin/config/plugin.json new file mode 100644 index 00000000000..2064907555c --- /dev/null +++ b/plugins/woocommerce-admin/config/plugin.json @@ -0,0 +1,8 @@ +{ + "features": { + "activity-panels": false, + "analytics": true, + "dashboard": true, + "devdocs": false + } +} \ No newline at end of file diff --git a/plugins/woocommerce-admin/docs/_sidebar.md b/plugins/woocommerce-admin/docs/_sidebar.md index a1ab06f9190..14b8998f4b8 100644 --- a/plugins/woocommerce-admin/docs/_sidebar.md +++ b/plugins/woocommerce-admin/docs/_sidebar.md @@ -1,5 +1,6 @@ * [Overview](/) * [Components](components/) +* [Feature Flags](feature-flags) * [Data](data) * [Documentation](documentation) * [Layout](layout) diff --git a/plugins/woocommerce-admin/docs/components/analytics/report-chart.md b/plugins/woocommerce-admin/docs/components/analytics/report-chart.md index efd3381b486..5184f564cba 100644 --- a/plugins/woocommerce-admin/docs/components/analytics/report-chart.md +++ b/plugins/woocommerce-admin/docs/components/analytics/report-chart.md @@ -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. diff --git a/plugins/woocommerce-admin/docs/components/analytics/report-summary.md b/plugins/woocommerce-admin/docs/components/analytics/report-summary.md index 8dd54053744..98a1e587462 100644 --- a/plugins/woocommerce-admin/docs/components/analytics/report-summary.md +++ b/plugins/woocommerce-admin/docs/components/analytics/report-summary.md @@ -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. + diff --git a/plugins/woocommerce-admin/docs/components/analytics/report-table.md b/plugins/woocommerce-admin/docs/components/analytics/report-table.md index 4800980f346..01da3a6404e 100644 --- a/plugins/woocommerce-admin/docs/components/analytics/report-table.md +++ b/plugins/woocommerce-admin/docs/components/analytics/report-table.md @@ -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`. diff --git a/plugins/woocommerce-admin/docs/components/packages/chart.md b/plugins/woocommerce-admin/docs/components/packages/chart.md index 37930f55a2d..49ff3733b3d 100644 --- a/plugins/woocommerce-admin/docs/components/packages/chart.md +++ b/plugins/woocommerce-admin/docs/components/packages/chart.md @@ -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 diff --git a/plugins/woocommerce-admin/docs/components/packages/search.md b/plugins/woocommerce-admin/docs/components/packages/search.md index 45c13c3fa4c..bdbfe917791 100644 --- a/plugins/woocommerce-admin/docs/components/packages/search.md +++ b/plugins/woocommerce-admin/docs/components/packages/search.md @@ -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 diff --git a/plugins/woocommerce-admin/docs/components/packages/table.md b/plugins/woocommerce-admin/docs/components/packages/table.md index 47c5a9bb734..6dec0a0c757 100644 --- a/plugins/woocommerce-admin/docs/components/packages/table.md +++ b/plugins/woocommerce-admin/docs/components/packages/table.md @@ -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 diff --git a/plugins/woocommerce-admin/docs/feature-flags.md b/plugins/woocommerce-admin/docs/feature-flags.md new file mode 100644 index 00000000000..bb321a8b894 --- /dev/null +++ b/plugins/woocommerce-admin/docs/feature-flags.md @@ -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' ] && } +``` + +We also expose CSS classes on the `body` tag, so that you can target specific feature states: + +``` + +``` + +## 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' ); +} +``` \ No newline at end of file diff --git a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-customers-controller.php b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-customers-controller.php index 4e606822433..89f8464f46c 100644 --- a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-customers-controller.php +++ b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-customers-controller.php @@ -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 ); diff --git a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-controller.php b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-controller.php index f599cbe5455..0e959a708d7 100644 --- a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-controller.php +++ b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-controller.php @@ -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; } } diff --git a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-stats-controller.php b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-stats-controller.php index d3d24b01f3f..87cf651cb9e 100644 --- a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-stats-controller.php +++ b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-customers-stats-controller.php @@ -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; } diff --git a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-orders-stats-controller.php b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-orders-stats-controller.php index 3fba1631a14..fd440dd9688 100644 --- a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-orders-stats-controller.php +++ b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-orders-stats-controller.php @@ -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, ), diff --git a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-stock-stats-controller.php b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-stock-stats-controller.php new file mode 100644 index 00000000000..960e4733766 --- /dev/null +++ b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-reports-stock-stats-controller.php @@ -0,0 +1,138 @@ +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; + } +} diff --git a/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php b/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php index f3fc1beb52a..fa98436ed9a 100644 --- a/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php +++ b/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php @@ -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(). diff --git a/plugins/woocommerce-admin/includes/class-wc-admin-install.php b/plugins/woocommerce-admin/includes/class-wc-admin-install.php new file mode 100644 index 00000000000..7f2ffe65aba --- /dev/null +++ b/plugins/woocommerce-admin/includes/class-wc-admin-install.php @@ -0,0 +1,264 @@ +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(); diff --git a/plugins/woocommerce-admin/includes/class-wc-admin-reports-segmenting.php b/plugins/woocommerce-admin/includes/class-wc-admin-reports-segmenting.php index b4e90050335..4469f0e6f1d 100644 --- a/plugins/woocommerce-admin/includes/class-wc-admin-reports-segmenting.php +++ b/plugins/woocommerce-admin/includes/class-wc-admin-reports-segmenting.php @@ -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. diff --git a/plugins/woocommerce-admin/includes/class-wc-admin-reports-stock-stats-query.php b/plugins/woocommerce-admin/includes/class-wc-admin-reports-stock-stats-query.php new file mode 100644 index 00000000000..297c19ede46 --- /dev/null +++ b/plugins/woocommerce-admin/includes/class-wc-admin-reports-stock-stats-query.php @@ -0,0 +1,29 @@ +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 ); + } + +} diff --git a/plugins/woocommerce-admin/includes/class-wc-admin-reports-sync.php b/plugins/woocommerce-admin/includes/class-wc-admin-reports-sync.php new file mode 100644 index 00000000000..c29cbd0bb95 --- /dev/null +++ b/plugins/woocommerce-admin/includes/class-wc-admin-reports-sync.php @@ -0,0 +1,411 @@ +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(); diff --git a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-customers-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-customers-data-store.php index 1af93335eb2..9a420100bef 100644 --- a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-customers-data-store.php +++ b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-customers-data-store.php @@ -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. * diff --git a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-downloads-stats-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-downloads-stats-data-store.php index 630cf892ce2..dcbca280cc7 100644 --- a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-downloads-stats-data-store.php +++ b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-downloads-stats-data-store.php @@ -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; } } diff --git a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-data-store.php index 508dd38bd3a..726eb387149 100644 --- a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-data-store.php +++ b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-data-store.php @@ -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 diff --git a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-stats-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-stats-data-store.php index d46253393a2..9918fd952bc 100644 --- a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-stats-data-store.php +++ b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-stats-data-store.php @@ -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; } /** diff --git a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-stock-stats-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-stock-stats-data-store.php new file mode 100644 index 00000000000..f75c824f427 --- /dev/null +++ b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-stock-stats-data-store.php @@ -0,0 +1,138 @@ +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' ); diff --git a/plugins/woocommerce-admin/lib/admin.php b/plugins/woocommerce-admin/lib/admin.php index 0ea2c254280..990d9cd09a4 100644 --- a/plugins/woocommerce-admin/lib/admin.php +++ b/plugins/woocommerce-admin/lib/admin.php @@ -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 '
'; @@ -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 '
'; diff --git a/plugins/woocommerce-admin/lib/common.php b/plugins/woocommerce-admin/lib/common.php index 0db63c09b70..1eb587f1719 100644 --- a/plugins/woocommerce-admin/lib/common.php +++ b/plugins/woocommerce-admin/lib/common.php @@ -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 ]; +} diff --git a/plugins/woocommerce-admin/package-lock.json b/plugins/woocommerce-admin/package-lock.json index b59a99319e2..09bc7e31fb1 100644 --- a/plugins/woocommerce-admin/package-lock.json +++ b/plugins/woocommerce-admin/package-lock.json @@ -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 } } }, diff --git a/plugins/woocommerce-admin/package.json b/plugins/woocommerce-admin/package.json index 255d5602b66..33f5820113e 100644 --- a/plugins/woocommerce-admin/package.json +++ b/plugins/woocommerce-admin/package.json @@ -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", diff --git a/plugins/woocommerce-admin/packages/components/CHANGELOG.md b/plugins/woocommerce-admin/packages/components/CHANGELOG.md index 41e375e8910..9baff23e2bf 100644 --- a/plugins/woocommerce-admin/packages/components/CHANGELOG.md +++ b/plugins/woocommerce-admin/packages/components/CHANGELOG.md @@ -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. diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js index 9e1104a9eb1..36211006a19 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js @@ -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 ( +
{ emptyMessage }
+ ); + } } render() { - const { className, data, height, type } = this.props; + const { className, data, height, orderedKeys, type } = this.props; const computedWidth = this.getWidth(); return (
+ { this.getEmptyMessage() }
@@ -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, diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/d3base/index.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/d3base/index.js index 5140f8ac8ac..1e06785e8d2 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/d3base/index.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/d3base/index.js @@ -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 ( -
+
); } } @@ -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, }; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js index 5dbaa1ccaae..3e8f63f8c38 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js @@ -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 ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.scss b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.scss index 1d574a3a91c..9dac64fd17a 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.scss +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.scss @@ -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; } diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/style.scss b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/style.scss index bcf7a38cfcd..b295155f7d1 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/style.scss +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/style.scss @@ -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 ); + } } diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/axis.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/axis.js index 7846a85b574..6d684b4c40c 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/axis.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/axis.js @@ -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(); }; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/bar-chart.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/bar-chart.js index dcfe1e49088..e1c1448640a 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/bar-chart.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/bar-chart.js @@ -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() ); }; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/index.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/index.js index 76f5c33a073..57dc268c284 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/index.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/index.js @@ -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; +}; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/line-chart.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/line-chart.js index aecff42e6e9..13350c5f478 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/line-chart.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/line-chart.js @@ -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() ); }; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/scales.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/scales.js index a50dc342866..26f2adb17a9 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/scales.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/scales.js @@ -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 ] ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/index.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/index.js index 85bae47e55d..a3b518f1454 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/index.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/index.js @@ -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(); } ); } ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/line-chart.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/line-chart.js new file mode 100644 index 00000000000..439946d6533 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/line-chart.js @@ -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' ); + } ); + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/scales.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/scales.js index a93dd2ff89a..bc591fee68b 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/scales.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/scales.js @@ -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 ] ); - } ); - } ); } ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/tooltip.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/tooltip.js index c72296c6531..139d7cc60bb 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/tooltip.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/tooltip.js @@ -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 => ` +
  • +
    + + + ${ this.getTooltipRowLabel( d, row ) } +
    + ${ this.valueFormat( d[ row.key ].value ) } +
  • + ` ); + + 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( ` +
    +

    ${ tooltipTitle }

    +
      + ${ keys.join( '' ) } +
    +
    + ` ); } +} - 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 => ` -
  • -
    - - - ${ getTooltipRowLabel( d, row, params ) } -
    - ${ params.tooltipValueFormat( d[ row.key ].value ) } -
  • - ` - ); - - 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( ` -
    -

    ${ tooltipTitle }

    -
      - ${ keys.join( '' ) } -
    -
    - ` ); -}; +export default ChartTooltip; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/index.js b/plugins/woocommerce-admin/packages/components/src/chart/index.js index 870691badde..9291b30b9a9 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/index.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/index.js @@ -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 && ( , rule: ( @@ -215,7 +216,7 @@ class DateFilter extends Component { } ); /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/ return ( -
    +
    { labels.add || '' }
    { 'SelectControl' === input.component && ( ) } } diff --git a/plugins/woocommerce-admin/packages/components/src/filters/advanced/number-filter.js b/plugins/woocommerce-admin/packages/components/src/filters/advanced/number-filter.js index c16b98f4dd4..830e66cbfb3 100644 --- a/plugins/woocommerce-admin/packages/components/src/filters/advanced/number-filter.js +++ b/plugins/woocommerce-admin/packages/components/src/filters/advanced/number-filter.js @@ -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: , rule: ( @@ -217,7 +218,7 @@ class NumberFilter extends Component { /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/ return ( -
    +
    { labels.add || '' } diff --git a/plugins/woocommerce-admin/packages/components/src/filters/advanced/search-filter.js b/plugins/woocommerce-admin/packages/components/src/filters/advanced/search-filter.js index 92189a5b006..d105d4c5dff 100644 --- a/plugins/woocommerce-admin/packages/components/src/filters/advanced/search-filter.js +++ b/plugins/woocommerce-admin/packages/components/src/filters/advanced/search-filter.js @@ -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: , rule: ( +
    { labels.add || '' } diff --git a/plugins/woocommerce-admin/packages/components/src/filters/advanced/select-filter.js b/plugins/woocommerce-admin/packages/components/src/filters/advanced/select-filter.js index b967e6daf97..d497f183e5a 100644 --- a/plugins/woocommerce-admin/packages/components/src/filters/advanced/select-filter.js +++ b/plugins/woocommerce-admin/packages/components/src/filters/advanced/select-filter.js @@ -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: , rule: ( +
    { labels.add || '' } diff --git a/plugins/woocommerce-admin/packages/components/src/filters/advanced/style.scss b/plugins/woocommerce-admin/packages/components/src/filters/advanced/style.scss index a373997aaee..278e9b16501 100644 --- a/plugins/woocommerce-admin/packages/components/src/filters/advanced/style.scss +++ b/plugins/woocommerce-admin/packages/components/src/filters/advanced/style.scss @@ -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 { diff --git a/plugins/woocommerce-admin/packages/components/src/search/autocomplete.js b/plugins/woocommerce-admin/packages/components/src/search/autocomplete.js index 32793da9919..79e23e73598 100644 --- a/plugins/woocommerce-admin/packages/components/src/search/autocomplete.js +++ b/plugins/woocommerce-admin/packages/components/src/search/autocomplete.js @@ -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(); } diff --git a/plugins/woocommerce-admin/packages/components/src/search/autocompleters/countries.js b/plugins/woocommerce-admin/packages/components/src/search/autocompleters/countries.js index 874d9ba5d0e..87242f74b14 100644 --- a/plugins/woocommerce-admin/packages/components/src/search/autocompleters/countries.js +++ b/plugins/woocommerce-admin/packages/components/src/search/autocompleters/countries.js @@ -20,6 +20,7 @@ import Flag from '../../flag'; export default { name: 'countries', className: 'woocommerce-search__country-result', + isDebounced: true, options() { return wcSettings.dataEndpoints.countries || []; }, diff --git a/plugins/woocommerce-admin/packages/components/src/search/autocompleters/customers.js b/plugins/woocommerce-admin/packages/components/src/search/autocompleters/customers.js index a55e9615d1d..c1c838a43ac 100644 --- a/plugins/woocommerce-admin/packages/components/src/search/autocompleters/customers.js +++ b/plugins/woocommerce-admin/packages/components/src/search/autocompleters/customers.js @@ -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 [ - + { match.suggestionBeforeMatch } { match.suggestionMatch } @@ -78,7 +76,7 @@ export default { getOptionCompletion( customer ) { return { id: customer.id, - label: getName( customer ), + label: customer.name, }; }, }; diff --git a/plugins/woocommerce-admin/packages/components/src/segmented-selection/ie.scss b/plugins/woocommerce-admin/packages/components/src/segmented-selection/ie.scss index 82dba64f037..725aea780e6 100644 --- a/plugins/woocommerce-admin/packages/components/src/segmented-selection/ie.scss +++ b/plugins/woocommerce-admin/packages/components/src/segmented-selection/ie.scss @@ -1,19 +1,5 @@ /** @format */ .woocommerce-segmented-selection__item { - display: block; @include set-grid-item-position( 2, 10 ); - - &:nth-child(2n) { - border-left: 1px solid $core-grey-light-700; - border-top: 1px solid $core-grey-light-700; - } - - &:nth-child(2n + 1) { - border-top: 1px solid $core-grey-light-700; - } - - &:nth-child(-n + 2) { - border-top: 0; - } } diff --git a/plugins/woocommerce-admin/packages/components/src/segmented-selection/style.scss b/plugins/woocommerce-admin/packages/components/src/segmented-selection/style.scss index 152c3980c34..8197bfaf7e7 100644 --- a/plugins/woocommerce-admin/packages/components/src/segmented-selection/style.scss +++ b/plugins/woocommerce-admin/packages/components/src/segmented-selection/style.scss @@ -14,6 +14,21 @@ background-color: $core-grey-light-700; } +.woocommerce-segmented-selection__item { + &:nth-child(2n) { + border-left: 1px solid $core-grey-light-700; + border-top: 1px solid $core-grey-light-700; + } + + &:nth-child(2n + 1) { + border-top: 1px solid $core-grey-light-700; + } + + &:nth-child(-n + 2) { + border-top: 0; + } +} + .woocommerce-segmented-selection__label { background-color: $core-grey-light-100; padding: $gap-small $gap-small $gap-small $gap-larger; diff --git a/plugins/woocommerce-admin/packages/components/src/table/index.js b/plugins/woocommerce-admin/packages/components/src/table/index.js index 4e7b1b3fbcf..c830069ca39 100644 --- a/plugins/woocommerce-admin/packages/components/src/table/index.js +++ b/plugins/woocommerce-admin/packages/components/src/table/index.js @@ -59,7 +59,8 @@ class TableCard extends Component { } componentDidUpdate( { query: prevQuery, headers: prevHeaders } ) { - const { compareBy, headers, query } = this.props; + const { compareBy, headers, onColumnsChange, query } = this.props; + const { showCols } = this.state; if ( query.filter || prevQuery.filter ) { const prevIds = prevQuery.filter ? getIdsFromQuery( prevQuery[ compareBy ] ) : []; @@ -79,6 +80,15 @@ class TableCard extends Component { } ); /* eslint-enable react/no-did-update-set-state */ } + if ( query.orderby !== prevQuery.orderby && ! showCols.includes( query.orderby ) ) { + const newShowCols = showCols.concat( query.orderby ); + /* eslint-disable react/no-did-update-set-state */ + this.setState( { + showCols: newShowCols, + } ); + /* eslint-enable react/no-did-update-set-state */ + onColumnsChange( newShowCols ); + } } getVisibleHeaders() { diff --git a/plugins/woocommerce-admin/readme.txt b/plugins/woocommerce-admin/readme.txt new file mode 100644 index 00000000000..dea6cad76ad --- /dev/null +++ b/plugins/woocommerce-admin/readme.txt @@ -0,0 +1,15 @@ +=== WooCommerce Admin === +Contributors: automattic +Tags: ecommerce, e-commerce, store, sales, reports, analytics, dashboard, activity, notices, insights, stats, woo commerce, woocommerce +Requires at least: 5.0.0 +Tested up to: 5.0.3 +Stable tag: 1.0.0 +License: GPLv2 +License URI: https://github.com/woocommerce/wc-admin/blob/master/LICENSE.md + +A feature plugin for a modern, javascript-driven WooCommerce admin experience. + +== Description == + + +== Changelog == diff --git a/plugins/woocommerce-admin/tests/api-init.php b/plugins/woocommerce-admin/tests/api-init.php index 60cfd2b9fcc..f4c54d1d5df 100644 --- a/plugins/woocommerce-admin/tests/api-init.php +++ b/plugins/woocommerce-admin/tests/api-init.php @@ -16,7 +16,7 @@ class WC_Tests_API_Init extends WC_REST_Unit_Test_Case { public function setUp() { parent::setUp(); $this->queue = new WC_Admin_Test_Action_Queue(); - WC_Admin_Api_Init::set_queue( $this->queue ); + WC_Admin_Reports_Sync::set_queue( $this->queue ); } /** @@ -24,7 +24,7 @@ class WC_Tests_API_Init extends WC_REST_Unit_Test_Case { */ public function tearDown() { parent::tearDown(); - WC_Admin_Api_Init::set_queue( null ); + WC_Admin_Reports_Sync::set_queue( null ); $this->queue->actions = array(); } @@ -72,13 +72,13 @@ class WC_Tests_API_Init extends WC_REST_Unit_Test_Case { add_filter( 'query', array( $this, 'filter_order_query' ) ); // Initiate sync. - WC_Admin_Api_Init::orders_lookup_process_order( $order->get_id() ); + WC_Admin_Reports_Sync::orders_lookup_process_order( $order->get_id() ); // Verify that a retry job was scheduled. $this->assertCount( 1, $this->queue->actions ); $this->assertArraySubset( array( - 'hook' => WC_Admin_Api_Init::SINGLE_ORDER_ACTION, + 'hook' => WC_Admin_Reports_Sync::SINGLE_ORDER_ACTION, 'args' => array( $order->get_id() ), ), $this->queue->actions[0] diff --git a/plugins/woocommerce-admin/tests/api/reports-customers-stats.php b/plugins/woocommerce-admin/tests/api/reports-customers-stats.php index 367f5c642e4..e4ab0ae34a9 100644 --- a/plugins/woocommerce-admin/tests/api/reports-customers-stats.php +++ b/plugins/woocommerce-admin/tests/api/reports-customers-stats.php @@ -59,9 +59,8 @@ class WC_Tests_API_Reports_Customers_Stats extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 2, $properties ); + $this->assertCount( 1, $properties ); $this->assertArrayHasKey( 'totals', $properties ); - $this->assertArrayHasKey( 'intervals', $properties ); $this->assertCount( 4, $properties['totals']['properties'] ); $this->assertArrayHasKey( 'customers_count', $properties['totals']['properties'] ); $this->assertArrayHasKey( 'avg_orders_count', $properties['totals']['properties'] ); @@ -142,7 +141,7 @@ class WC_Tests_API_Reports_Customers_Stats extends WC_REST_Unit_Test_Case { // Test name parameter (case with no matches). $request->set_query_params( array( - 'name' => 'Nota Customername', + 'search' => 'Nota Customername', ) ); $response = $this->server->dispatch( $request ); @@ -157,8 +156,8 @@ class WC_Tests_API_Reports_Customers_Stats extends WC_REST_Unit_Test_Case { // Test name and last_order parameters. $request->set_query_params( array( - 'name' => 'Jeff', - 'last_order_after' => date( 'Y-m-d' ) . 'T00:00:00Z', + 'search' => 'Jeff', + 'last_order_after' => date( 'Y-m-d' ) . 'T00:00:00Z', ) ); $response = $this->server->dispatch( $request ); diff --git a/plugins/woocommerce-admin/tests/api/reports-customers.php b/plugins/woocommerce-admin/tests/api/reports-customers.php index f2386a45201..6602c4f5d1d 100644 --- a/plugins/woocommerce-admin/tests/api/reports-customers.php +++ b/plugins/woocommerce-admin/tests/api/reports-customers.php @@ -52,7 +52,7 @@ class WC_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case { * @param array $schema Item to check schema. */ public function assert_report_item_schema( $schema ) { - $this->assertArrayHasKey( 'customer_id', $schema ); + $this->assertArrayHasKey( 'id', $schema ); $this->assertArrayHasKey( 'user_id', $schema ); $this->assertArrayHasKey( 'name', $schema ); $this->assertArrayHasKey( 'username', $schema ); @@ -163,7 +163,7 @@ class WC_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case { // Test name parameter (case with no matches). $request->set_query_params( array( - 'name' => 'Nota Customername', + 'search' => 'Nota Customername', ) ); $response = $this->server->dispatch( $request ); @@ -175,8 +175,8 @@ class WC_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case { // Test name and last_order parameters. $request->set_query_params( array( - 'name' => 'Justin', - 'last_order_after' => date( 'Y-m-d' ) . 'T00:00:00Z', + 'search' => 'Justin', + 'last_order_after' => date( 'Y-m-d' ) . 'T00:00:00Z', ) ); $response = $this->server->dispatch( $request ); diff --git a/plugins/woocommerce-admin/tests/api/reports-stock-stats.php b/plugins/woocommerce-admin/tests/api/reports-stock-stats.php new file mode 100644 index 00000000000..bf238e1563f --- /dev/null +++ b/plugins/woocommerce-admin/tests/api/reports-stock-stats.php @@ -0,0 +1,139 @@ +user = $this->factory->user->create( + array( + 'role' => 'administrator', + ) + ); + } + + /** + * Test route registration. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + + $this->assertArrayHasKey( $this->endpoint, $routes ); + } + + /** + * Test getting reports. + */ + public function test_get_reports() { + wp_set_current_user( $this->user ); + WC_Helper_Reports::reset_stats_dbs(); + + $number_of_low_stock = 3; + for ( $i = 1; $i <= $number_of_low_stock; $i++ ) { + $low_stock = new WC_Product_Simple(); + $low_stock->set_name( "Test low stock {$i}" ); + $low_stock->set_regular_price( 5 ); + $low_stock->set_manage_stock( true ); + $low_stock->set_stock_quantity( 1 ); + $low_stock->set_stock_status( 'instock' ); + $low_stock->save(); + } + + $number_of_out_of_stock = 6; + for ( $i = 1; $i <= $number_of_out_of_stock; $i++ ) { + $out_of_stock = new WC_Product_Simple(); + $out_of_stock->set_name( "Test out of stock {$i}" ); + $out_of_stock->set_regular_price( 5 ); + $out_of_stock->set_stock_status( 'outofstock' ); + $out_of_stock->save(); + } + + $number_of_in_stock = 10; + for ( $i = 1; $i <= $number_of_in_stock; $i++ ) { + $in_stock = new WC_Product_Simple(); + $in_stock->set_name( "Test in stock {$i}" ); + $in_stock->set_regular_price( 25 ); + $in_stock->save(); + } + + $request = new WP_REST_Request( 'GET', $this->endpoint ); + $response = $this->server->dispatch( $request ); + $reports = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + + $this->assertArrayHasKey( 'totals', $reports ); + $this->assertEquals( 19, $reports['totals']['products'] ); + $this->assertEquals( 6, $reports['totals']['outofstock'] ); + $this->assertEquals( 0, $reports['totals']['onbackorder'] ); + $this->assertEquals( 3, $reports['totals']['lowstock'] ); + $this->assertEquals( 13, $reports['totals']['instock'] ); + + // Test backorder and cache update. + $backorder_stock = new WC_Product_Simple(); + $backorder_stock->set_name( 'Test backorder' ); + $backorder_stock->set_regular_price( 5 ); + $backorder_stock->set_stock_status( 'onbackorder' ); + $backorder_stock->save(); + + $request = new WP_REST_Request( 'GET', $this->endpoint ); + $response = $this->server->dispatch( $request ); + $reports = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + + $this->assertEquals( 20, $reports['totals']['products'] ); + $this->assertEquals( 6, $reports['totals']['outofstock'] ); + $this->assertEquals( 1, $reports['totals']['onbackorder'] ); + $this->assertEquals( 3, $reports['totals']['lowstock'] ); + $this->assertEquals( 13, $reports['totals']['instock'] ); + } + + /** + * Test getting reports without valid permissions. + */ + public function test_get_reports_without_permission() { + wp_set_current_user( 0 ); + $response = $this->server->dispatch( new WP_REST_Request( 'GET', $this->endpoint ) ); + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test reports schema. + */ + public function test_reports_schema() { + wp_set_current_user( $this->user ); + + $request = new WP_REST_Request( 'OPTIONS', $this->endpoint ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertCount( 1, $properties ); + $this->assertArrayHasKey( 'totals', $properties ); + $this->assertCount( 5, $properties['totals']['properties'] ); + $this->assertArrayHasKey( 'products', $properties['totals']['properties'] ); + $this->assertArrayHasKey( 'outofstock', $properties['totals']['properties'] ); + $this->assertArrayHasKey( 'onbackorder', $properties['totals']['properties'] ); + $this->assertArrayHasKey( 'lowstock', $properties['totals']['properties'] ); + $this->assertArrayHasKey( 'instock', $properties['totals']['properties'] ); + } +} diff --git a/plugins/woocommerce-admin/tests/batch-queue.php b/plugins/woocommerce-admin/tests/batch-queue.php index afb3079ab4f..480c92018e6 100644 --- a/plugins/woocommerce-admin/tests/batch-queue.php +++ b/plugins/woocommerce-admin/tests/batch-queue.php @@ -43,11 +43,11 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { */ public function filter_batch_size( $batch_size, $action ) { switch ( $action ) { - case WC_Admin_Api_Init::QUEUE_BATCH_ACTION: + case WC_Admin_Reports_Sync::QUEUE_BATCH_ACTION: return $this->queue_batch_size; - case WC_Admin_Api_Init::CUSTOMERS_BATCH_ACTION: + case WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION: return $this->customers_batch_size; - case WC_Admin_Api_Init::ORDERS_BATCH_ACTION: + case WC_Admin_Reports_Sync::ORDERS_BATCH_ACTION: return $this->orders_batch_size; default: return 1; @@ -60,7 +60,7 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { public function setUp() { parent::setUp(); $this->queue = new WC_Admin_Test_Action_Queue(); - WC_Admin_Api_Init::set_queue( $this->queue ); + WC_Admin_Reports_Sync::set_queue( $this->queue ); add_filter( 'wc_admin_report_regenerate_batch_size', array( $this, 'filter_batch_size' ), 10, 2 ); } @@ -69,7 +69,7 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { */ public function tearDown() { parent::tearDown(); - WC_Admin_Api_Init::set_queue( null ); + WC_Admin_Reports_Sync::set_queue( null ); $this->queue->actions = array(); remove_filter( 'wc_admin_report_regenerate_batch_size', array( $this, 'filter_batch_size' ), 10, 2 ); } @@ -81,20 +81,20 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { $num_customers = 1234; // 1234 / 5 = 247 batches $num_batches = ceil( $num_customers / $this->customers_batch_size ); - WC_Admin_Api_Init::queue_batches( 1, $num_batches, WC_Admin_Api_Init::CUSTOMERS_BATCH_ACTION ); + WC_Admin_Reports_Sync::queue_batches( 1, $num_batches, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION ); $this->assertCount( $this->queue_batch_size, $this->queue->actions ); $this->assertArraySubset( array( - 'hook' => WC_Admin_Api_Init::QUEUE_BATCH_ACTION, - 'args' => array( 1, 25, WC_Admin_Api_Init::CUSTOMERS_BATCH_ACTION ), + 'hook' => WC_Admin_Reports_Sync::QUEUE_BATCH_ACTION, + 'args' => array( 1, 25, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION ), ), $this->queue->actions[0] ); $this->assertArraySubset( array( - 'hook' => WC_Admin_Api_Init::QUEUE_BATCH_ACTION, - 'args' => array( 226, 247, WC_Admin_Api_Init::CUSTOMERS_BATCH_ACTION ), + 'hook' => WC_Admin_Reports_Sync::QUEUE_BATCH_ACTION, + 'args' => array( 226, 247, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION ), ), $this->queue->actions[ $this->queue_batch_size - 1 ] ); @@ -107,19 +107,19 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { $num_customers = 45; // 45 / 5 = 9 batches (which is less than the batch queue size) $num_batches = ceil( $num_customers / $this->customers_batch_size ); - WC_Admin_Api_Init::queue_batches( 1, $num_batches, WC_Admin_Api_Init::CUSTOMERS_BATCH_ACTION ); + WC_Admin_Reports_Sync::queue_batches( 1, $num_batches, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION ); $this->assertCount( 9, $this->queue->actions ); $this->assertArraySubset( array( - 'hook' => WC_Admin_Api_Init::CUSTOMERS_BATCH_ACTION, + 'hook' => WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION, 'args' => array( 1 ), ), $this->queue->actions[0] ); $this->assertArraySubset( array( - 'hook' => WC_Admin_Api_Init::CUSTOMERS_BATCH_ACTION, + 'hook' => WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION, 'args' => array( 9 ), ), $this->queue->actions[8] @@ -131,15 +131,15 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { */ public function test_queue_dependent_action() { // reset back to using a real queue. - WC_Admin_Api_Init::set_queue( null ); + WC_Admin_Reports_Sync::set_queue( null ); // insert a blocking job. - WC_Admin_Api_Init::queue()->schedule_single( time(), 'blocking_job', array( 'stuff' ) ); + WC_Admin_Reports_Sync::queue()->schedule_single( time(), 'blocking_job', array( 'stuff' ) ); // queue an action that depends on blocking job. - WC_Admin_Api_Init::queue_dependent_action( 'dependent_action', array(), 'blocking_job' ); + WC_Admin_Reports_Sync::queue_dependent_action( 'dependent_action', array(), 'blocking_job' ); // verify that the action was properly blocked. $this->assertEmpty( - WC_Admin_Api_Init::queue()->search( + WC_Admin_Reports_Sync::queue()->search( array( 'hook' => 'dependent_action', ) @@ -148,20 +148,20 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { // verify that a follow up action was queued. $this->assertCount( 1, - WC_Admin_Api_Init::queue()->search( + WC_Admin_Reports_Sync::queue()->search( array( - 'hook' => WC_Admin_Api_Init::QUEUE_DEPEDENT_ACTION, + 'hook' => WC_Admin_Reports_Sync::QUEUE_DEPEDENT_ACTION, 'args' => array( 'dependent_action', array(), 'blocking_job' ), ) ) ); // queue an action that isn't blocked. - WC_Admin_Api_Init::queue_dependent_action( 'another_dependent_action', array(), 'nonexistant_blocking_job' ); + WC_Admin_Reports_Sync::queue_dependent_action( 'another_dependent_action', array(), 'nonexistant_blocking_job' ); // verify that the dependent action was queued. $this->assertCount( 1, - WC_Admin_Api_Init::queue()->search( + WC_Admin_Reports_Sync::queue()->search( array( 'hook' => 'another_dependent_action', ) @@ -169,16 +169,16 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case { ); // verify that no follow up action was queued. $this->assertEmpty( - WC_Admin_Api_Init::queue()->search( + WC_Admin_Reports_Sync::queue()->search( array( - 'hook' => WC_Admin_Api_Init::QUEUE_DEPEDENT_ACTION, + 'hook' => WC_Admin_Reports_Sync::QUEUE_DEPEDENT_ACTION, 'args' => array( 'another_dependent_action', array(), 'nonexistant_blocking_job' ), ) ) ); // clean up. - WC_Admin_Api_Init::queue()->cancel_all( 'another_dependent_action' ); - WC_Admin_Api_Init::queue()->cancel_all( WC_Admin_Api_Init::QUEUE_DEPEDENT_ACTION ); + WC_Admin_Reports_Sync::queue()->cancel_all( 'another_dependent_action' ); + WC_Admin_Reports_Sync::queue()->cancel_all( WC_Admin_Reports_Sync::QUEUE_DEPEDENT_ACTION ); } } diff --git a/plugins/woocommerce-admin/tests/bootstrap.php b/plugins/woocommerce-admin/tests/bootstrap.php index 0b2ebe86311..8b1a1337199 100755 --- a/plugins/woocommerce-admin/tests/bootstrap.php +++ b/plugins/woocommerce-admin/tests/bootstrap.php @@ -37,21 +37,17 @@ function wc_admin_install() { define( 'WC_REMOVE_ALL_DATA', true ); // Initialize the WC API extensions. - require_once dirname( dirname( __FILE__ ) ) . '/includes/class-wc-admin-api-init.php'; + require_once dirname( dirname( __FILE__ ) ) . '/includes/class-wc-admin-install.php'; - WC_Admin_Api_Init::install(); + WC_Admin_Install::create_tables(); if ( ! wp_next_scheduled( 'wc_admin_daily' ) ) { wp_schedule_event( time(), 'daily', 'wc_admin_daily' ); } // Reload capabilities after install, see https://core.trac.wordpress.org/ticket/28374. - if ( version_compare( $GLOBALS['wp_version'], '4.7', '<' ) ) { - $GLOBALS['wp_roles']->reinit(); - } else { - $GLOBALS['wp_roles'] = null; // WPCS: override ok. - wp_roles(); - } + $GLOBALS['wp_roles'] = null; // WPCS: override ok. + wp_roles(); echo esc_html( 'Installing wc-admin...' . PHP_EOL ); } @@ -94,16 +90,6 @@ function wc_test_includes() { * Manually load the plugin being tested. */ function _manually_load_plugin() { - if ( version_compare( $GLOBALS['wp_version'], '4.9.9', '<=' ) ) { // < 5.0 fails for "5.0-alpha-12345-src - $_tests_wp_core_dir = getenv( 'WP_CORE_DIR' ); - - if ( ! $_tests_wp_core_dir ) { - $_tests_wp_core_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress'; - } - - require $_tests_wp_core_dir . '/wp-content/plugins/gutenberg/gutenberg.php'; - } - define( 'WC_TAX_ROUNDING_MODE', 'auto' ); define( 'WC_USE_TRANSACTIONS', false ); require_once wc_dir() . '/woocommerce.php'; @@ -124,3 +110,16 @@ require_once dirname( __FILE__ ) . '/framework/helpers/class-wc-helper-reports.p require_once dirname( __FILE__ ) . '/framework/helpers/class-wc-helper-admin-notes.php'; require_once dirname( __FILE__ ) . '/framework/helpers/class-wc-test-action-queue.php'; require_once dirname( __FILE__ ) . '/framework/helpers/class-wc-helper-queue.php'; + +/** + * Set the environment and feature flags to 'develop' when running tests, instead of what is set in the generated `config/feature-flags.php`. + * This matches how we mock the flags in jest. + */ +function wc_admin_get_feature_config() { + $config = json_decode( file_get_contents( dirname( dirname( __FILE__ ) ) . '/config/development.json' ) ); + $flags = array(); + foreach ( $config->features as $feature => $bool ) { + $flags[ $feature ] = $bool; + } + return $flags; +} diff --git a/plugins/woocommerce-admin/tests/js/setup-globals.js b/plugins/woocommerce-admin/tests/js/setup-globals.js index d3ed144ed8c..33a750cf44e 100644 --- a/plugins/woocommerce-admin/tests/js/setup-globals.js +++ b/plugins/woocommerce-admin/tests/js/setup-globals.js @@ -64,4 +64,7 @@ global.wcSettings = { }, }; +const config = require( '../../config/development.json' ); +window.wcAdminFeatures = config && config.features ? config.features : {}; + setLocaleData( { '': { domain: 'wc-admin', lang: 'en_US' } }, 'wc-admin' ); diff --git a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-orders-stats.php b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-orders-stats.php index c405dd485cb..82cc50fa713 100644 --- a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-orders-stats.php +++ b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-orders-stats.php @@ -178,9 +178,6 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { $order->set_shipping_total( 0 ); $order->set_cart_tax( 0 ); $order->save(); - - // Wait one second to avoid potentially ambiguous new/returning customer. - sleep( 1 ); // @todo Remove this after p90Yrv-XN-p2 is resolved. } WC_Helper_Queue::run_all_pending(); @@ -208,8 +205,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'taxes' => 0, 'shipping' => 0, 'net_revenue' => 100, - 'num_returning_customers' => 1, - 'num_new_customers' => 0, + 'num_returning_customers' => 0, + 'num_new_customers' => 1, 'products' => 1, 'segments' => array(), ), @@ -231,8 +228,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'num_items_sold' => 4, 'avg_items_per_order' => 4, 'avg_order_value' => 100, - 'num_returning_customers' => 1, - 'num_new_customers' => 0, + 'num_returning_customers' => 0, + 'num_new_customers' => 1, 'segments' => array(), ), ), @@ -990,8 +987,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), // product 3 and product 4 (that is sometimes included in the orders with product 3). @@ -1014,8 +1011,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1064,8 +1061,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 3, 'segments' => array(), ), @@ -1087,8 +1084,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1136,8 +1133,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), ), @@ -1159,8 +1156,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1211,8 +1208,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), ), @@ -1234,8 +1231,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1288,8 +1285,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 4, 'segments' => array(), ), @@ -1311,8 +1308,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1362,8 +1359,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 4, 'segments' => array(), ), @@ -1385,8 +1382,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => 2, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1591,8 +1588,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 4, 'segments' => array(), ), @@ -1614,8 +1611,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1635,18 +1632,11 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'customer' => 'new', ); - $orders_count = 144; - $num_items_sold = $orders_count / 2 * $qty_per_product - + $orders_count / 2 * $qty_per_product * 2; - $coupons = $orders_count; + $orders_count = 2; + $num_items_sold = $orders_count * $qty_per_product; + $coupons = 0; $shipping = $orders_count * 10; - $net_revenue = $product_1_price * $qty_per_product * ( $orders_count / 6 ) - + $product_2_price * $qty_per_product * ( $orders_count / 6 ) - + $product_3_price * $qty_per_product * ( $orders_count / 6 ) - + ( $product_1_price + $product_4_price ) * $qty_per_product * ( $orders_count / 6 ) - + ( $product_2_price + $product_4_price ) * $qty_per_product * ( $orders_count / 6 ) - + ( $product_3_price + $product_4_price ) * $qty_per_product * ( $orders_count / 6 ) - - $coupons; + $net_revenue = $product_1_price * $qty_per_product * $orders_count; $gross_revenue = $net_revenue + $shipping; $expected_stats = array( @@ -1663,7 +1653,7 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'avg_order_value' => $net_revenue / $orders_count, 'num_returning_customers' => 0, 'num_new_customers' => $new_customers, - 'products' => 4, + 'products' => 1, 'segments' => array(), ), 'intervals' => array( @@ -1697,15 +1687,6 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { $this->assertEquals( $expected_stats, json_decode( json_encode( $data_store->get_data( $query_args ) ), true ), 'Query args: ' . print_r( $query_args, true ) . "; query: {$wpdb->last_query}" ); // ** Customer returning - $returning_order = WC_Helper_Order::create_order( $customer_1->get_id(), $product ); - $returning_order->set_status( 'completed' ); - $returning_order->set_shipping_total( 10 ); - $returning_order->set_total( 110 ); // $25x4 products + $10 shipping. - $returning_order->set_date_created( $order_1_time + 1 ); // This is guaranteed to belong to the same hour by the adjustment to $order_1_time. - $returning_order->save(); - - WC_Helper_Queue::run_all_pending(); - $query_args = array( 'after' => $current_hour_start->format( WC_Admin_Reports_Interval::$sql_datetime_format ), // I don't think this makes sense.... date( 'Y-m-d H:i:s', $orders[0]->get_date_created()->getOffsetTimestamp() + 1 ), // Date after initial order to get a returning customer. 'before' => $current_hour_end->format( WC_Admin_Reports_Interval::$sql_datetime_format ), @@ -1713,15 +1694,23 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'customer' => 'returning', ); - $order_permutations = 72; + $total_orders_count = 144; + $returning_orders_count = 2; $order_w_coupon_1_perms = 24; $order_w_coupon_2_perms = 24; - $orders_count = 1; - $num_items_sold = 4; - $coupons = 0; + $orders_count = $total_orders_count - $returning_orders_count; + $num_items_sold = $total_orders_count * 6 - ( $returning_orders_count * 4 ); + $coupons = count( $this_['hour'] ) * ( $order_w_coupon_1_perms * $coupon_1_amount + $order_w_coupon_2_perms * $coupon_2_amount ); $shipping = $orders_count * 10; - $net_revenue = 100; + $net_revenue = $product_1_price * $qty_per_product * ( $total_orders_count / 6 ) + + $product_2_price * $qty_per_product * ( $total_orders_count / 6 ) + + $product_3_price * $qty_per_product * ( $total_orders_count / 6 ) + + ( $product_1_price + $product_4_price ) * $qty_per_product * ( $total_orders_count / 6 ) + + ( $product_2_price + $product_4_price ) * $qty_per_product * ( $total_orders_count / 6 ) + + ( $product_3_price + $product_4_price ) * $qty_per_product * ( $total_orders_count / 6 ) + - $product_1_price * $qty_per_product * $returning_orders_count + - $coupons; $gross_revenue = $net_revenue + $shipping; $expected_stats = array( @@ -1734,11 +1723,11 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'taxes' => 0, 'shipping' => $shipping, 'net_revenue' => $net_revenue, - 'avg_items_per_order' => $num_items_sold, + 'avg_items_per_order' => round( $num_items_sold / $orders_count, 4 ), 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 1, + 'num_returning_customers' => $returning_orders_count, 'num_new_customers' => 0, - 'products' => 1, + 'products' => 4, 'segments' => array(), ), 'intervals' => array( @@ -1757,9 +1746,9 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'taxes' => 0, 'shipping' => $shipping, 'net_revenue' => $net_revenue, - 'avg_items_per_order' => $num_items_sold, + 'avg_items_per_order' => round( $num_items_sold / $orders_count, 4 ), 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 1, + 'num_returning_customers' => $returning_orders_count, 'num_new_customers' => 0, 'segments' => array(), ), @@ -1771,7 +1760,6 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { ); $this->assertEquals( $expected_stats, json_decode( json_encode( $data_store->get_data( $query_args ) ), true ), 'Query args: ' . print_r( $query_args, true ) . "; query: {$wpdb->last_query};" ); - wp_delete_post( $returning_order->get_id(), true ); // Combinations: match all // status_is + product_includes. @@ -1891,8 +1879,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 4, 'segments' => array(), ), @@ -1914,8 +1902,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -1965,8 +1953,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), ), @@ -1988,8 +1976,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -2042,8 +2030,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), ), @@ -2065,8 +2053,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -2123,8 +2111,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), ), @@ -2146,8 +2134,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -2289,8 +2277,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), ), @@ -2312,8 +2300,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -2377,8 +2365,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'products' => 2, 'segments' => array(), ), @@ -2400,8 +2388,8 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { 'net_revenue' => $net_revenue, 'avg_items_per_order' => $num_items_sold / $orders_count, 'avg_order_value' => $net_revenue / $orders_count, - 'num_returning_customers' => 0, - 'num_new_customers' => $new_customers, + 'num_returning_customers' => 2, + 'num_new_customers' => 0, 'segments' => array(), ), ), @@ -4732,7 +4720,6 @@ class WC_Tests_Reports_Orders_Stats extends WC_Unit_Test_Case { ) as $order_time ) { // Order with 1 product. - sleep( 1 ); // @todo Remove this after p90Yrv-XN-p2 is resolved. $order = WC_Helper_Order::create_order( $customer->get_id(), $product ); $order->set_date_created( $order_time ); $order->set_status( $order_status ); diff --git a/plugins/woocommerce-admin/wc-admin.php b/plugins/woocommerce-admin/wc-admin.php index b933745e927..02146e2d2d6 100755 --- a/plugins/woocommerce-admin/wc-admin.php +++ b/plugins/woocommerce-admin/wc-admin.php @@ -7,7 +7,7 @@ * Author URI: https://woocommerce.com/ * Text Domain: wc-admin * Domain Path: /languages - * Version: 0.6.0 + * Version: 0.7.0 * * @package WC_Admin */ @@ -25,7 +25,7 @@ if ( ! defined( 'WC_ADMIN_PLUGIN_FILE' ) ) { } /** - * Notify users of the plugin requirements + * Notify users of the plugin requirements. */ function wc_admin_plugins_notice() { // The notice varies by WordPress version. @@ -40,9 +40,9 @@ function wc_admin_plugins_notice() { ); } else { $message = sprintf( - /* translators: 1: URL of Gutenberg plugin, 2: URL of WooCommerce plugin */ - __( 'The WooCommerce Admin feature plugin requires both Gutenberg and WooCommerce (>3.5) to be installed and active.', 'wc-admin' ), - 'https://wordpress.org/plugins/gutenberg/', + /* translators: 1: URL of WordPress.org, 2: URL of WooCommerce plugin */ + __( 'The WooCommerce Admin feature plugin requires both WordPress 5.0 or greater and WooCommerce 3.5 or greater to be installed and active.', 'wc-admin' ), + 'https://wordpress.org/', 'https://wordpress.org/plugins/woocommerce/' ); } @@ -50,7 +50,7 @@ function wc_admin_plugins_notice() { } /** - * Notify users that the plugin needs to be built + * Notify users that the plugin needs to be built. */ function wc_admin_build_notice() { $message_one = __( 'You have installed a development version of WooCommerce Admin which requires files to be built. From the plugin directory, run npm install to install dependencies, npm run build to build the files.', 'wc-admin' ); @@ -73,11 +73,8 @@ function dependencies_satisfied() { return false; } - $wordpress_version = get_bloginfo( 'version' ); - $wordpress_includes_gutenberg = version_compare( $wordpress_version, '4.9.9', '>' ); - $gutenberg_plugin_active = defined( 'GUTENBERG_DEVELOPMENT_MODE' ) || defined( 'GUTENBERG_VERSION' ); - - return $wordpress_includes_gutenberg || $gutenberg_plugin_active; + $wordpress_version = get_bloginfo( 'version' ); + return version_compare( $wordpress_version, '4.9.9', '>' ); } /** @@ -98,16 +95,12 @@ function do_wc_admin_daily() { add_action( 'wc_admin_daily', 'do_wc_admin_daily' ); /** - * Activates wc-admin plugin when installed. + * Initializes wc-admin daily action when plugin activated. */ function activate_wc_admin_plugin() { if ( ! dependencies_satisfied() ) { return; } - // Initialize the WC API extensions. - require_once dirname( __FILE__ ) . '/includes/class-wc-admin-api-init.php'; - - WC_Admin_Api_Init::install(); if ( ! wp_next_scheduled( 'wc_admin_daily' ) ) { wp_schedule_event( time(), 'daily', 'wc_admin_daily' ); @@ -134,23 +127,6 @@ function deactivate_wc_admin_plugin() { } register_deactivation_hook( WC_ADMIN_PLUGIN_FILE, 'deactivate_wc_admin_plugin' ); -/** - * Update the database tables if needed. This hooked function does NOT need to - * be ported to WooCommerce's code base - WC_Install will do this on plugin - * update automatically. - */ -function wc_admin_init() { - if ( ! dependencies_satisfied() ) { - return; - } - - // Only create/update tables on init if WP_DEBUG is true. - if ( defined( 'WP_DEBUG' ) && WP_DEBUG && wc_admin_build_file_exists() ) { - WC_Admin_Api_Init::create_db_tables(); - } -} -add_action( 'init', 'wc_admin_init' ); - /** * Set up the plugin, only if we can detect both Gutenberg and WooCommerce */ @@ -160,7 +136,13 @@ function wc_admin_plugins_loaded() { return; } + if ( ! function_exists( 'wc_admin_get_feature_config' ) ) { + require_once dirname( __FILE__ ) . '/includes/feature-config.php'; + } + // Initialize the WC API extensions. + require_once dirname( __FILE__ ) . '/includes/class-wc-admin-reports-sync.php'; + require_once dirname( __FILE__ ) . '/includes/class-wc-admin-install.php'; require_once dirname( __FILE__ ) . '/includes/class-wc-admin-api-init.php'; // Some common utilities. diff --git a/plugins/woocommerce-admin/webpack.config.js b/plugins/woocommerce-admin/webpack.config.js index 328947b73d2..acf8a4e841b 100644 --- a/plugins/woocommerce-admin/webpack.config.js +++ b/plugins/woocommerce-admin/webpack.config.js @@ -6,6 +6,7 @@ const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); const { get } = require( 'lodash' ); const path = require( 'path' ); const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); +const { DefinePlugin } = require( 'webpack' ); /** * WordPress dependencies @@ -14,6 +15,13 @@ const CustomTemplatedPathPlugin = require( '@wordpress/custom-templated-path-web const NODE_ENV = process.env.NODE_ENV || 'development'; +// @todo Add a `beta` phase and build process so that we can separate final .org versions from beta GitHub versions. +let WC_ADMIN_PHASE = process.env.WC_ADMIN_PHASE || 'core'; +if ( [ 'development', 'plugin', 'core' ].indexOf( WC_ADMIN_PHASE ) === -1 ) { + WC_ADMIN_PHASE = 'core'; +} +const WC_ADMIN_CONFIG = require( path.join( __dirname, 'config', WC_ADMIN_PHASE + '.json' ) ); + const externals = { '@wordpress/api-fetch': { this: [ 'wp', 'apiFetch' ] }, '@wordpress/blocks': { this: [ 'wp', 'blocks' ] }, @@ -136,6 +144,10 @@ const webpackConfig = { }, }, plugins: [ + // Inject the current feature flags. + new DefinePlugin( { + 'window.wcAdminFeatures': { ...WC_ADMIN_CONFIG.features }, + } ), new CustomTemplatedPathPlugin( { modulename( outputPath, data ) { const entryName = get( data, [ 'chunk', 'name' ] );