diff --git a/plugins/woocommerce-admin/client/analytics/report/categories/breadcrumbs.js b/plugins/woocommerce-admin/client/analytics/report/categories/breadcrumbs.js new file mode 100644 index 00000000000..544434dc4a1 --- /dev/null +++ b/plugins/woocommerce-admin/client/analytics/report/categories/breadcrumbs.js @@ -0,0 +1,67 @@ +/** @format */ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import { first, last } from 'lodash'; +import { Spinner } from '@wordpress/components'; + +/** + * WooCommerce dependencies + */ +import { Link } from '@woocommerce/components'; + +export default class CategoryBreadcrumbs extends Component { + getCategoryAncestorIds( category, categories ) { + const ancestors = []; + let parent = category.parent; + while ( parent ) { + ancestors.unshift( parent ); + parent = categories[ parent ].parent; + } + return ancestors; + } + + getCategoryAncestors( category, categories ) { + const ancestorIds = this.getCategoryAncestorIds( category, categories ); + + if ( ! ancestorIds.length ) { + return; + } + if ( ancestorIds.length === 1 ) { + return categories[ first( ancestorIds ) ].name + ' › '; + } + if ( ancestorIds.length === 2 ) { + return ( + categories[ first( ancestorIds ) ].name + + ' › ' + + categories[ last( ancestorIds ) ].name + + ' › ' + ); + } + return ( + categories[ first( ancestorIds ) ].name + + ' … ' + + categories[ last( ancestorIds ) ].name + + ' › ' + ); + } + + render() { + const { categories, category } = this.props; + + return category ? ( +
+ { this.getCategoryAncestors( category, categories ) } + + { category.name } + +
+ ) : ( + + ); + } +} diff --git a/plugins/woocommerce-admin/client/analytics/report/categories/table.js b/plugins/woocommerce-admin/client/analytics/report/categories/table.js index 8eb3632b8ce..30ab59dd82c 100644 --- a/plugins/woocommerce-admin/client/analytics/report/categories/table.js +++ b/plugins/woocommerce-admin/client/analytics/report/categories/table.js @@ -4,21 +4,23 @@ */ import { __, _n } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; import { map } from 'lodash'; /** * WooCommerce dependencies */ -import { Link } from '@woocommerce/components'; import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency'; /** * Internal dependencies */ -import ReportTable from 'analytics/components/report-table'; +import CategoryBreacrumbs from './breadcrumbs'; import { numberFormat } from 'lib/number'; +import ReportTable from 'analytics/components/report-table'; +import withSelect from 'wc-api/with-select'; -export default class CategoriesReportTable extends Component { +class CategoriesReportTable extends Component { constructor( props ) { super( props ); @@ -64,29 +66,16 @@ export default class CategoriesReportTable extends Component { ]; } - getRowsContent( categories ) { - return map( categories, category => { - const { - category_id, - items_sold, - net_revenue, - products_count, - orders_count, - extended_info, - } = category; - const { name } = extended_info; + getRowsContent( categoryStats ) { + return map( categoryStats, categoryStat => { + const { category_id, items_sold, net_revenue, products_count, orders_count } = categoryStat; + const categories = this.props.categories; + const category = categories[ category_id ]; return [ { - display: ( - - { name } - - ), - value: name, + display: , + value: category && category.name, }, { display: numberFormat( items_sold ), @@ -156,3 +145,15 @@ export default class CategoriesReportTable extends Component { ); } } + +export default compose( + withSelect( select => { + const { getCategories, getCategoriesError, isGetCategoriesRequesting } = select( 'wc-api' ); + + const categories = getCategories(); + const isError = Boolean( getCategoriesError() ); + const isRequesting = isGetCategoriesRequesting(); + + return { categories, isError, isRequesting }; + } ) +)( CategoriesReportTable ); diff --git a/plugins/woocommerce-admin/client/analytics/report/products/style.scss b/plugins/woocommerce-admin/client/analytics/report/products/style.scss new file mode 100644 index 00000000000..d2a80f105fa --- /dev/null +++ b/plugins/woocommerce-admin/client/analytics/report/products/style.scss @@ -0,0 +1,16 @@ +/** @format */ + +.woocommerce-table__product-categories { + > .woocommerce-table__breadcrumbs { + display: inline-block; + margin-right: $gap-small; + } + .components-popover__content { + padding: 0 $gap; + text-align: left; + } + .components-popover__content .woocommerce-table__breadcrumbs { + margin-top: $gap-small; + margin-bottom: $gap-small; + } +} diff --git a/plugins/woocommerce-admin/client/analytics/report/products/table.js b/plugins/woocommerce-admin/client/analytics/report/products/table.js index 007875a96a3..9c9b4aa856e 100644 --- a/plugins/woocommerce-admin/client/analytics/report/products/table.js +++ b/plugins/woocommerce-admin/client/analytics/report/products/table.js @@ -2,25 +2,29 @@ /** * External dependencies */ -import { __, _n, _x } from '@wordpress/i18n'; +import { __, _n, _x, sprintf } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; import { map } from 'lodash'; /** * WooCommerce dependencies */ -import { Link } from '@woocommerce/components'; import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency'; import { getNewPath, getPersistedQuery } from '@woocommerce/navigation'; +import { Link, Tag } from '@woocommerce/components'; /** * Internal dependencies */ -import ReportTable from 'analytics/components/report-table'; -import { numberFormat } from 'lib/number'; +import CategoryBreacrumbs from '../categories/breadcrumbs'; import { isLowStock } from './utils'; +import { numberFormat } from 'lib/number'; +import ReportTable from 'analytics/components/report-table'; +import withSelect from 'wc-api/with-select'; +import './style.scss'; -export default class ProductsReportTable extends Component { +class ProductsReportTable extends Component { constructor() { super(); @@ -96,10 +100,9 @@ export default class ProductsReportTable extends Component { items_sold, net_revenue, orders_count, - categories = [], // @TODO variations = [], // @TODO } = row; - const { name, stock_status, stock_quantity, low_stock_amount } = extended_info; + const { category_ids, low_stock_amount, name, stock_status, stock_quantity } = extended_info; const ordersLink = getNewPath( persistedQuery, 'orders', { filter: 'advanced', product_includes: product_id, @@ -108,6 +111,8 @@ export default class ProductsReportTable extends Component { filter: 'single_product', products: product_id, } ); + const categories = this.props.categories; + const productCategories = category_ids.map( category_id => categories[ category_id ] ); return [ { @@ -139,10 +144,29 @@ export default class ProductsReportTable extends Component { value: orders_count, }, { - display: Array.isArray( categories ) - ? categories.map( cat => cat.name ).join( ', ' ) - : '', - value: Array.isArray( categories ) ? categories.map( cat => cat.name ).join( ', ' ) : '', + display: ( +
+ { productCategories[ 0 ] && ( + + ) } + { productCategories.length > 1 && ( + ( + + ) ) } + /> + ) } +
+ ), + value: productCategories.map( category => category.name ).join( ', ' ), }, { display: numberFormat( variations.length ), @@ -219,3 +243,15 @@ export default class ProductsReportTable extends Component { ); } } + +export default compose( + withSelect( select => { + const { getCategories, getCategoriesError, isGetCategoriesRequesting } = select( 'wc-api' ); + + const categories = getCategories(); + const isError = Boolean( getCategoriesError() ); + const isRequesting = isGetCategoriesRequesting(); + + return { categories, isError, isRequesting }; + } ) +)( ProductsReportTable ); diff --git a/plugins/woocommerce-admin/client/wc-api/categories/index.js b/plugins/woocommerce-admin/client/wc-api/categories/index.js new file mode 100644 index 00000000000..cd0cff2f807 --- /dev/null +++ b/plugins/woocommerce-admin/client/wc-api/categories/index.js @@ -0,0 +1,11 @@ +/** @format */ +/** + * Internal dependencies + */ +import operations from './operations'; +import selectors from './selectors'; + +export default { + operations, + selectors, +}; diff --git a/plugins/woocommerce-admin/client/wc-api/categories/operations.js b/plugins/woocommerce-admin/client/wc-api/categories/operations.js new file mode 100644 index 00000000000..bcf891917ef --- /dev/null +++ b/plugins/woocommerce-admin/client/wc-api/categories/operations.js @@ -0,0 +1,54 @@ +/** @format */ + +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * WooCommerce dependencies + */ +import { stringifyQuery } from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import { isResourcePrefix, getResourceIdentifier, getResourceName } from '../utils'; + +function read( resourceNames, fetch = apiFetch ) { + const filteredNames = resourceNames.filter( name => isResourcePrefix( name, 'category-query' ) ); + + return filteredNames.map( async resourceName => { + const query = getResourceIdentifier( resourceName ); + const url = `/wc/v3/products/categories${ stringifyQuery( query ) }`; + + try { + const response = await fetch( { + parse: false, + path: url, + } ); + + const categories = await response.json(); + const totalCount = parseInt( response.headers.get( 'x-wp-total' ) ); + const ids = categories.map( category => category.id ); + const categoryResources = categories.reduce( ( resources, category ) => { + resources[ getResourceName( 'category', category.id ) ] = { data: category }; + return resources; + }, {} ); + + return { + [ resourceName ]: { + data: ids, + totalCount, + }, + ...categoryResources, + }; + } catch ( error ) { + return { [ resourceName ]: { error } }; + } + } ); +} + +export default { + read, +}; diff --git a/plugins/woocommerce-admin/client/wc-api/categories/selectors.js b/plugins/woocommerce-admin/client/wc-api/categories/selectors.js new file mode 100644 index 00000000000..b5d0257d7b7 --- /dev/null +++ b/plugins/woocommerce-admin/client/wc-api/categories/selectors.js @@ -0,0 +1,56 @@ +/** @format */ + +/** + * External dependencies + */ +import { isNil } from 'lodash'; + +/** + * Internal dependencies + */ +import { getResourceName } from '../utils'; +import { DEFAULT_REQUIREMENT } from '../constants'; + +const getCategories = ( getResource, requireResource ) => ( + query = {}, + requirement = DEFAULT_REQUIREMENT +) => { + const resourceName = getResourceName( 'category-query', query ); + const ids = requireResource( requirement, resourceName ).data || []; + const categories = ids.reduce( + ( acc, id ) => ( { + ...acc, + [ id ]: getResource( getResourceName( 'category', id ) ).data || {}, + } ), + {} + ); + return categories; +}; + +const getCategoriesTotalCount = getResource => ( query = {} ) => { + const resourceName = getResourceName( 'category-query', query ); + return getResource( resourceName ).totalCount || 0; +}; + +const getCategoriesError = getResource => ( query = {} ) => { + const resourceName = getResourceName( 'category-query', query ); + return getResource( resourceName ).error; +}; + +const isGetCategoriesRequesting = getResource => ( query = {} ) => { + const resourceName = getResourceName( 'category-query', query ); + const { lastRequested, lastReceived } = getResource( resourceName ); + + if ( isNil( lastRequested ) || isNil( lastReceived ) ) { + return true; + } + + return lastRequested > lastReceived; +}; + +export default { + getCategories, + getCategoriesError, + getCategoriesTotalCount, + isGetCategoriesRequesting, +}; diff --git a/plugins/woocommerce-admin/client/wc-api/wc-api-spec.js b/plugins/woocommerce-admin/client/wc-api/wc-api-spec.js index 6bc9b887a9c..b14f3a5ef07 100644 --- a/plugins/woocommerce-admin/client/wc-api/wc-api-spec.js +++ b/plugins/woocommerce-admin/client/wc-api/wc-api-spec.js @@ -3,6 +3,7 @@ /** * Internal dependencies */ +import categories from './categories'; import customers from './customers'; import notes from './notes'; import orders from './orders'; @@ -17,6 +18,7 @@ function createWcApiSpec() { ...user.mutations, }, selectors: { + ...categories.selectors, ...customers.selectors, ...notes.selectors, ...orders.selectors, @@ -28,6 +30,7 @@ function createWcApiSpec() { operations: { read( resourceNames ) { return [ + ...categories.operations.read( resourceNames ), ...customers.operations.read( resourceNames ), ...notes.operations.read( resourceNames ), ...orders.operations.read( resourceNames ), 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 95c2bd2d242..85823327952 100644 --- a/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php +++ b/plugins/woocommerce-admin/includes/class-wc-admin-api-init.php @@ -29,6 +29,10 @@ class WC_Admin_Api_Init { // Initialize Orders data store class's static vars. add_action( 'woocommerce_after_register_post_type', array( 'WC_Admin_Api_Init', 'orders_data_store_init' ), 20 ); + // Add taxonomy support for product categories. + add_filter( 'woocommerce_taxonomy_args_product_cat', array( 'WC_Admin_Api_Init', 'show_product_categories_in_rest' ) ); + // Increase per_page limit in REST response. + add_filter( 'woocommerce_rest_product_cat_query', array( 'WC_Admin_Api_Init', 'increase_per_page_limit' ) ); } /** @@ -470,6 +474,28 @@ class WC_Admin_Api_Init { add_action( 'woocommerce_after_register_post_type', array( 'WC_Admin_Api_Init', 'order_product_lookup_store_init' ), 20 ); } + /** + * Enables the WP REST API for product categories + * + * @param array $args Default arguments for product_cat taxonomy. + * @return array + */ + public static function show_product_categories_in_rest( $args ) { + $args['show_in_rest'] = true; + return $args; + } + + /** + * Increase per page limit for product categories + * + * @param array $prepared_args Prepared arguments for query. + * @return array + */ + public static function increase_per_page_limit( $prepared_args ) { + $prepared_args['number'] = PHP_INT_MAX; + return $prepared_args; + } + } new WC_Admin_Api_Init(); diff --git a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-products-data-store.php b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-products-data-store.php index 32ef3da268b..747073ef37f 100644 --- a/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-products-data-store.php +++ b/plugins/woocommerce-admin/includes/data-stores/class-wc-admin-reports-products-data-store.php @@ -25,17 +25,21 @@ class WC_Admin_Reports_Products_Data_Store extends WC_Admin_Reports_Data_Store i * @var array */ protected $column_types = array( - 'date_start' => 'strval', - 'date_end' => 'strval', - 'product_id' => 'intval', - 'items_sold' => 'intval', - 'net_revenue' => 'floatval', - 'orders_count' => 'intval', + 'date_start' => 'strval', + 'date_end' => 'strval', + 'product_id' => 'intval', + 'items_sold' => 'intval', + 'net_revenue' => 'floatval', + 'orders_count' => 'intval', // Extended attributes. - 'name' => 'strval', - 'price' => 'floatval', - 'image' => 'strval', - 'permalink' => 'strval', + 'name' => 'strval', + 'price' => 'floatval', + 'image' => 'strval', + 'permalink' => 'strval', + 'stock_status' => 'strval', + 'stock_quantity' => 'intval', + 'low_stock_amount' => 'intval', + 'category_ids' => 'array_values', ); /** @@ -63,6 +67,7 @@ class WC_Admin_Reports_Products_Data_Store extends WC_Admin_Reports_Data_Store i 'stock_status', 'stock_quantity', 'low_stock_amount', + 'category_ids', ); /** diff --git a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-products.php b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-products.php index d8036795063..d329f24593a 100644 --- a/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-products.php +++ b/plugins/woocommerce-admin/tests/reports/class-wc-tests-reports-products.php @@ -199,6 +199,9 @@ class WC_Tests_Reports_Products extends WC_Unit_Test_Case { $product->set_low_stock_amount( 5 ); $product->save(); + $term = wp_insert_term( 'Test Category', 'product_cat' ); + wp_set_object_terms( $product->get_id(), $term['term_id'], 'product_cat' ); + $order = WC_Helper_Order::create_order( 1, $product ); $order->set_status( 'completed' ); $order->set_shipping_total( 10 ); @@ -217,7 +220,9 @@ class WC_Tests_Reports_Products extends WC_Unit_Test_Case { 'extended_info' => 1, ); // Test retrieving the stats through the data store. - $data = $data_store->get_data( $args ); + $data = $data_store->get_data( $args ); + // Get updated product data. + $product = wc_get_product( $product->get_id() ); $expected_data = (object) array( 'total' => 1, 'pages' => 1, @@ -234,8 +239,9 @@ class WC_Tests_Reports_Products extends WC_Unit_Test_Case { 'permalink' => $product->get_permalink(), 'price' => (float) $product->get_price(), 'stock_status' => $product->get_stock_status(), - 'stock_quantity' => $product->get_stock_quantity() - 4, // subtract the ones purchased. + 'stock_quantity' => $product->get_stock_quantity(), 'low_stock_amount' => $product->get_low_stock_amount(), + 'category_ids' => array_values( $product->get_category_ids() ), ), ), ),