diff --git a/plugins/woocommerce-admin/client/analytics/report/customers/config.js b/plugins/woocommerce-admin/client/analytics/report/customers/config.js index fe8bdc82b42..c201d7d291c 100644 --- a/plugins/woocommerce-admin/client/analytics/report/customers/config.js +++ b/plugins/woocommerce-admin/client/analytics/report/customers/config.js @@ -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, } ) ), }, }, 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..06c4c44d87d 100644 --- a/plugins/woocommerce-admin/client/analytics/report/stock/table.js +++ b/plugins/woocommerce-admin/client/analytics/report/stock/table.js @@ -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/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/wc-api/constants.js b/plugins/woocommerce-admin/client/wc-api/constants.js index 91513917478..596c9d3634c 100644 --- a/plugins/woocommerce-admin/client/wc-api/constants.js +++ b/plugins/woocommerce-admin/client/wc-api/constants.js @@ -11,7 +11,7 @@ export const SWAGGERNAMESPACE = 'https://virtserver.swaggerhub.com/peterfabian/w 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..40b8b9525af 100644 --- a/plugins/woocommerce-admin/client/wc-api/reports/stats/operations.js +++ b/plugins/woocommerce-admin/client/wc-api/reports/stats/operations.js @@ -21,6 +21,7 @@ const statEndpoints = [ 'orders', 'products', 'revenue', + 'stock', 'taxes', 'customers', ]; @@ -34,6 +35,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', }; 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/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/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 4986c133fd1..fa98436ed9a 100644 --- a/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php +++ b/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php @@ -68,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'; @@ -85,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'; @@ -100,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'; @@ -131,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', @@ -163,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', @@ -366,22 +370,23 @@ 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', ) ); } 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/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 0e1f4b446f1..62cabec799d 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. } @@ -516,6 +524,24 @@ 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'; + + return $wpdb->get_results( + $wpdb->prepare( + "SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d 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-orders-stats-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-orders-stats-data-store.php index 6ffaf2900d6..89d7d33a53f 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 @@ -515,24 +515,57 @@ 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]; + + // Order is older than previous first order. + if ( $order->get_date_created() < new WC_DateTime( $first_order->date_created ) ) { + self::set_customer_first_order( $customer_id, $order->get_id() ); + return false; + } + // First order date has changed and next oldest is now the first order. + $second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false; + if ( + (int) $order->get_id() === (int) $first_order->order_id && + $order->get_date_created() > new WC_DateTime( $first_order->date_created ) && + $second_order && + new WC_DateTime( $second_order->date_created ) < $order->get_date_created() + ) { + 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/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 e4340ee70b1..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,7 +16,7 @@ "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": "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", 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 53eb6a14936..73a2486d700 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js @@ -6,18 +6,14 @@ 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, @@ -28,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. @@ -45,28 +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, chartType } = 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 ( chartType === '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, chartType } = this.props; + const newOrderedKeys = orderedKeys || getOrderedKeys( data ); + + return { + colorScheme, + interval, + mode, + chartType, + 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, chartType } = 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, chartType } = 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 = chartType === 'line' && adjParams.uniqueDates.length <= 1 - ? adjParams.width / 2 - : 0; + this.createTooltip( g.node(), params.visibleKeys ); - drawAxis( g, adjParams, xOffset ); - chartType === 'line' && drawLines( g, data, adjParams, xOffset ); - chartType === 'bar' && drawBars( g, data, adjParams ); + drawAxis( g, params, scales, formats, margin ); + chartType === 'line' && drawLines( g, data, params, scales, formats, this.tooltip ); + chartType === 'bar' && drawBars( g, data, params, scales, formats, this.tooltip ); } shouldBeCompact() { @@ -92,75 +149,6 @@ 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, - chartType, - 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, - chartType, - 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; @@ -172,7 +160,7 @@ class D3Chart extends Component { } render() { - const { className, data, height, chartType } = this.props; + const { className, data, height, orderedKeys, chartType } = this.props; const computedWidth = this.getWidth(); return (
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 78abe142c42..566017f70bc 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, chartType: PropTypes.string, }; 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 fd9090c1801..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,6 +10,7 @@ .d3-chart__container { position: relative; + width: 100%; svg { overflow: visible; @@ -157,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 13939e97849..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.chartType === '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.chartType === '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 a0002b1ac05..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, isNil } 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,93 +35,21 @@ 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 ) ); - -/** - * 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 - */ -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 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 ) ); +}; /** * Check whether data is empty. 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 9207a90ea9e..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,24 +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', () => { @@ -35,67 +25,12 @@ 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 ) ); - } ); - - 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' ); - } ); - } ); - } ); -} ); - -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( 'isDataEmpty', () => { it( 'should return true when all data values are 0 and no baseValue is provided', () => { const data = [ 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/filters/advanced/search-filter.js b/plugins/woocommerce-admin/packages/components/src/filters/advanced/search-filter.js index 92189a5b006..dec99989dc7 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 ) { diff --git a/plugins/woocommerce-admin/packages/components/src/ie.scss b/plugins/woocommerce-admin/packages/components/src/ie.scss index d14445447f1..42d0131e959 100644 --- a/plugins/woocommerce-admin/packages/components/src/ie.scss +++ b/plugins/woocommerce-admin/packages/components/src/ie.scss @@ -1,6 +1,5 @@ /** * Internal Dependencies */ -@import 'segmented-selection/ie.scss'; @import 'summary/ie.scss'; @import 'table/ie.scss'; 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 deleted file mode 100644 index 82dba64f037..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/segmented-selection/ie.scss +++ /dev/null @@ -1,19 +0,0 @@ -/** @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..04efce1bf90 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,24 @@ background-color: $core-grey-light-700; } +.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; + } +} + .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/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/reports/class-wc-tests-reports-orders-stats.php b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-orders-stats.php index c405dd485cb..f63384ab35a 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(); @@ -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 a1e6e5a6adf..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 */