diff --git a/plugins/woocommerce-admin/client/header/activity-panel/index.js b/plugins/woocommerce-admin/client/header/activity-panel/index.js index c606b31749e..5ccc5460156 100644 --- a/plugins/woocommerce-admin/client/header/activity-panel/index.js +++ b/plugins/woocommerce-admin/client/header/activity-panel/index.js @@ -15,8 +15,13 @@ import { partial, uniqueId, find } from 'lodash'; */ import './style.scss'; import ActivityPanelToggleBubble from './toggle-bubble'; -import { DEFAULT_ACTIONABLE_STATUSES, DEFAULT_REVIEW_STATUSES } from 'wc-api/constants'; import { H, Section } from '@woocommerce/components'; +import { + getUnreadNotes, + getUnreadOrders, + getUnreadReviews, + getUnreadStock, +} from './unread-indicators'; import InboxPanel from './panels/inbox'; import OrdersPanel from './panels/orders'; import StockPanel from './panels/stock'; @@ -93,26 +98,26 @@ class ActivityPanel extends Component { // @todo Pull in dynamic unread status/count getTabs() { - const { unreadNotes, unreadOrders, unreadReviews } = this.props; + const { hasUnreadNotes, hasUnreadOrders, hasUnreadReviews, hasUnreadStock } = this.props; return [ { name: 'inbox', title: __( 'Inbox', 'woocommerce-admin' ), icon: , - unread: unreadNotes, + unread: hasUnreadNotes, }, { name: 'orders', title: __( 'Orders', 'woocommerce-admin' ), icon: , - unread: unreadOrders, + unread: hasUnreadOrders, }, 'yes' === wcSettings.manageStock ? { name: 'stock', title: __( 'Stock', 'woocommerce-admin' ), icon: , - unread: false, + unread: hasUnreadStock, } : null, 'yes' === wcSettings.reviewsEnabled @@ -120,7 +125,7 @@ class ActivityPanel extends Component { name: 'reviews', title: __( 'Reviews', 'woocommerce-admin' ), icon: , - unread: unreadReviews, + unread: hasUnreadReviews, } : null, ].filter( Boolean ); @@ -131,8 +136,8 @@ class ActivityPanel extends Component { case 'inbox': return ; case 'orders': - const { unreadOrders } = this.props; - return ; + const { hasUnreadOrders } = this.props; + return ; case 'stock': return ; case 'reviews': @@ -264,101 +269,10 @@ class ActivityPanel extends Component { } export default withSelect( select => { - const { - getCurrentUserData, - getNotes, - getNotesError, - getReportItems, - getReportItemsError, - isGetNotesRequesting, - isReportItemsRequesting, - getReviews, - getReviewsTotalCount, - getReviewsError, - isGetReviewsRequesting, - } = select( 'wc-api' ); - const userData = getCurrentUserData(); - const orderStatuses = - wcSettings.wcAdminSettings.woocommerce_actionable_order_statuses || DEFAULT_ACTIONABLE_STATUSES; + const hasUnreadNotes = getUnreadNotes( select ); + const hasUnreadOrders = getUnreadOrders( select ); + const hasUnreadStock = getUnreadStock( select ); + const { numberOfReviews, hasUnreadReviews } = getUnreadReviews( select ); - const notesQuery = { - page: 1, - per_page: 1, - }; - - const latestNote = getNotes( notesQuery ); - const unreadNotes = - ! Boolean( getNotesError( notesQuery ) ) && - ! isGetNotesRequesting( notesQuery ) && - latestNote[ 0 ] && - new Date( latestNote[ 0 ].date_created_gmt ).getTime() > - userData.activity_panel_inbox_last_read; - - let unreadOrders = null; - if ( ! orderStatuses.length ) { - unreadOrders = false; - } else { - const ordersQuery = { - page: 1, - per_page: 0, - status_is: orderStatuses, - }; - - const totalOrders = getReportItems( 'orders', ordersQuery ).totalResults; - const isOrdersError = Boolean( getReportItemsError( 'orders', ordersQuery ) ); - const isOrdersRequesting = isReportItemsRequesting( 'orders', ordersQuery ); - - if ( ! isOrdersError && ! isOrdersRequesting ) { - if ( totalOrders > 0 ) { - unreadOrders = true; - } else { - unreadOrders = false; - } - } - } - - let numberOfReviews = null; - let unreadReviews = false; - if ( 'yes' === wcSettings.reviewsEnabled ) { - const reviewsQuery = { - order: 'desc', - orderby: 'date_gmt', - page: 1, - per_page: 1, - status: DEFAULT_REVIEW_STATUSES, - }; - const reviews = getReviews( reviewsQuery ); - const totalReviews = getReviewsTotalCount( reviewsQuery ); - const isReviewsError = Boolean( getReviewsError( reviewsQuery ) ); - const isReviewsRequesting = isGetReviewsRequesting( reviewsQuery ); - - if ( ! isReviewsError && ! isReviewsRequesting ) { - numberOfReviews = totalReviews; - unreadReviews = Boolean( - reviews.length && - reviews[ 0 ].date_created_gmt && - new Date( reviews[ 0 ].date_created_gmt + 'Z' ).getTime() > - userData.activity_panel_reviews_last_read - ); - } - - if ( ! unreadReviews && '1' === wcSettings.commentModeration ) { - const actionableReviewsQuery = { - page: 1, - // @todo we are not using this review, so when the endpoint supports it, - // it could be replaced with `per_page: 0` - per_page: 1, - status: 'hold', - }; - const totalActionableReviews = getReviewsTotalCount( actionableReviewsQuery ); - const isActionableReviewsError = Boolean( getReviewsError( actionableReviewsQuery ) ); - const isActionableReviewsRequesting = isGetReviewsRequesting( actionableReviewsQuery ); - - if ( ! isActionableReviewsError && ! isActionableReviewsRequesting ) { - unreadReviews = totalActionableReviews > 0; - } - } - } - - return { unreadNotes, unreadOrders, unreadReviews, numberOfReviews }; + return { hasUnreadNotes, hasUnreadOrders, hasUnreadReviews, hasUnreadStock, numberOfReviews }; } )( clickOutside( ActivityPanel ) ); diff --git a/plugins/woocommerce-admin/client/header/activity-panel/unread-indicators.js b/plugins/woocommerce-admin/client/header/activity-panel/unread-indicators.js new file mode 100644 index 00000000000..c90c33f643b --- /dev/null +++ b/plugins/woocommerce-admin/client/header/activity-panel/unread-indicators.js @@ -0,0 +1,105 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { DEFAULT_ACTIONABLE_STATUSES, DEFAULT_REVIEW_STATUSES } from 'wc-api/constants'; + +export function getUnreadNotes( select ) { + const { getCurrentUserData, getNotes, getNotesError, isGetNotesRequesting } = select( 'wc-api' ); + const userData = getCurrentUserData(); + const notesQuery = { + page: 1, + per_page: 1, + }; + + const latestNote = getNotes( notesQuery ); + return ( + ! Boolean( getNotesError( notesQuery ) ) && + ! isGetNotesRequesting( notesQuery ) && + latestNote[ 0 ] && + new Date( latestNote[ 0 ].date_created_gmt ).getTime() > userData.activity_panel_inbox_last_read + ); +} + +export function getUnreadOrders( select ) { + const { getReportItems, getReportItemsError, isReportItemsRequesting } = select( 'wc-api' ); + const orderStatuses = wcSettings.wcAdminSettings.woocommerce_actionable_order_statuses || DEFAULT_ACTIONABLE_STATUSES; + + if ( ! orderStatuses.length ) { + return false; + } + + const ordersQuery = { + page: 1, + per_page: 0, + status_is: orderStatuses, + }; + + const totalOrders = getReportItems( 'orders', ordersQuery ).totalResults; + const isError = Boolean( getReportItemsError( 'orders', ordersQuery ) ); + const isRequesting = isReportItemsRequesting( 'orders', ordersQuery ); + + if ( ! isError && ! isRequesting ) { + if ( totalOrders > 0 ) { + return true; + } + return false; + } + return null; +} + +export function getUnreadReviews( select ) { + const { + getCurrentUserData, + getReviews, + getReviewsTotalCount, + getReviewsError, + isGetReviewsRequesting, + } = select( 'wc-api' ); + const userData = getCurrentUserData(); + let numberOfReviews = null; + let hasUnreadReviews = false; + + if ( 'yes' === wcSettings.reviewsEnabled ) { + const reviewsQuery = { + order: 'desc', + orderby: 'date_gmt', + page: 1, + per_page: 1, + status: DEFAULT_REVIEW_STATUSES, + }; + const reviews = getReviews( reviewsQuery ); + const totalReviews = getReviewsTotalCount( reviewsQuery ); + const isReviewsError = Boolean( getReviewsError( reviewsQuery ) ); + const isReviewsRequesting = isGetReviewsRequesting( reviewsQuery ); + + if ( ! isReviewsError && ! isReviewsRequesting ) { + numberOfReviews = totalReviews; + hasUnreadReviews = + reviews.length && + reviews[ 0 ].date_created_gmt && + new Date( reviews[ 0 ].date_created_gmt + 'Z' ).getTime() > + userData.activity_panel_reviews_last_read; + } + } + + return { numberOfReviews, hasUnreadReviews }; +} + +export function getUnreadStock( select ) { + const { getItems, getItemsError, getItemsTotalCount, isGetItemsRequesting } = select( 'wc-api' ); + const productsQuery = { + page: 1, + per_page: 1, + low_in_stock: true, + status: 'publish', + }; + getItems( 'products', productsQuery ); + const lowInStockCount = getItemsTotalCount( 'products', productsQuery ); + + return ! getItemsError( 'products', productsQuery ) && + ! isGetItemsRequesting( 'products', productsQuery ) + ? lowInStockCount > 0 + : false; +} diff --git a/plugins/woocommerce-admin/client/wc-api/items/operations.js b/plugins/woocommerce-admin/client/wc-api/items/operations.js index b6589727c7b..862c5c73e82 100644 --- a/plugins/woocommerce-admin/client/wc-api/items/operations.js +++ b/plugins/woocommerce-admin/client/wc-api/items/operations.js @@ -38,11 +38,14 @@ function read( resourceNames, fetch = apiFetch ) { const url = NAMESPACE + `/${ endpoint }${ stringifyQuery( query ) }`; try { - const items = await fetch( { + const response = await fetch( { + parse: false, path: url, } ); + const items = await response.json(); const ids = items.map( item => item.id ); + const totalCount = parseInt( response.headers.get( 'x-wp-total' ) ); const itemResources = items.reduce( ( resources, item ) => { resources[ getResourceName( `${ prefix }-item`, item.id ) ] = { data: item }; return resources; @@ -51,7 +54,7 @@ function read( resourceNames, fetch = apiFetch ) { return { [ resourceName ]: { data: ids, - totalCount: ids.length, + totalCount, }, ...itemResources, }; diff --git a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-products-controller.php b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-products-controller.php index 12bcd9c002e..5c6da3a201d 100644 --- a/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-products-controller.php +++ b/plugins/woocommerce-admin/includes/api/class-wc-admin-rest-products-controller.php @@ -55,8 +55,14 @@ class WC_Admin_REST_Products_Controller extends WC_REST_Products_Controller { * @return array */ public function get_collection_params() { - $params = parent::get_collection_params(); - $params['search'] = array( + $params = parent::get_collection_params(); + $params['low_in_stock'] = array( + 'description' => __( 'Limit result set to products that are low or out of stock.', 'woocommerce-admin' ), + 'type' => 'boolean', + 'default' => false, + 'sanitize_callback' => 'wc_string_to_bool', + ); + $params['search'] = array( 'description' => __( 'Search by similar product name or sku.', 'woocommerce-admin' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', @@ -75,9 +81,12 @@ class WC_Admin_REST_Products_Controller extends WC_REST_Products_Controller { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['search'] ) ) { - $args['search'] = $request['search']; + $args['search'] = trim( $request['search'] ); unset( $args['s'] ); } + if ( ! empty( $request['low_in_stock'] ) ) { + $args['low_in_stock'] = $request['low_in_stock']; + } return $args; } @@ -89,50 +98,71 @@ class WC_Admin_REST_Products_Controller extends WC_REST_Products_Controller { * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { - add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_product_search_filter' ), 10, 2 ); - add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_product_search_join' ), 10, 2 ); - add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_product_search_group_by' ), 10, 2 ); + add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 ); + add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 ); + add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 ); $response = parent::get_items( $request ); - remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_product_search_filter' ), 10 ); - remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_product_search_join' ), 10 ); - remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_product_search_group_by' ), 10 ); + remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 ); + remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 ); + remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 ); return $response; } /** - * Allow searching by product name or sku in WP Query + * Add in conditional search filters for products. * * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ - public static function add_wp_query_product_search_filter( $where, $wp_query ) { + public static function add_wp_query_filter( $where, $wp_query ) { global $wpdb; - $search = trim( $wp_query->get( 'search' ) ); + $search = $wp_query->get( 'search' ); if ( $search ) { $search = $wpdb->esc_like( $search ); $search = "'%" . $search . "%'"; - $where .= ' AND (' . $wpdb->posts . '.post_title LIKE ' . $search; + $where .= " AND ({$wpdb->posts}.post_title LIKE {$search}"; $where .= wc_product_sku_enabled() ? ' OR ps_post_meta.meta_key = "_sku" AND ps_post_meta.meta_value LIKE ' . $search . ')' : ')'; } + if ( $wp_query->get( 'low_in_stock' ) ) { + $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); + $where .= " AND lis_postmeta2.meta_key = '_manage_stock' + AND lis_postmeta2.meta_value = 'yes' + AND lis_postmeta.meta_key = '_stock' + AND lis_postmeta.meta_value IS NOT NULL + AND lis_postmeta3.meta_key = '_low_stock_amount' + AND ( + lis_postmeta3.meta_value > '' + AND CAST(lis_postmeta.meta_value AS SIGNED) <= CAST(lis_postmeta3.meta_value AS SIGNED) + OR lis_postmeta3.meta_value <= '' + AND CAST(lis_postmeta.meta_value AS SIGNED) <= {$low_stock_amount} + )"; + } + return $where; } /** - * Join posts meta table when product search is present and meta query is not present. + * Join posts meta tables when product search or low stock query is present. * * @param string $join Join clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ - public static function add_wp_query_product_search_join( $join, $wp_query ) { + public static function add_wp_query_join( $join, $wp_query ) { global $wpdb; - $search = trim( $wp_query->get( 'search' ) ); + $search = $wp_query->get( 'search' ); if ( $search && wc_product_sku_enabled() ) { - $join .= ' INNER JOIN ' . $wpdb->postmeta . ' AS ps_post_meta ON ps_post_meta.post_id = ' . $wpdb->posts . '.ID'; + $join .= " INNER JOIN {$wpdb->postmeta} AS ps_post_meta ON ps_post_meta.post_id = {$wpdb->posts}.ID"; + } + + if ( $wp_query->get( 'low_in_stock' ) ) { + $join .= " INNER JOIN {$wpdb->postmeta} AS lis_postmeta ON {$wpdb->posts}.ID = lis_postmeta.post_id + INNER JOIN {$wpdb->postmeta} AS lis_postmeta2 ON {$wpdb->posts}.ID = lis_postmeta2.post_id + INNER JOIN {$wpdb->postmeta} AS lis_postmeta3 ON {$wpdb->posts}.ID = lis_postmeta3.post_id"; } return $join; @@ -145,14 +175,14 @@ class WC_Admin_REST_Products_Controller extends WC_REST_Products_Controller { * @param object $wp_query WP_Query object. * @return string */ - public function add_wp_query_product_search_group_by( $groupby, $wp_query ) { + public static function add_wp_query_group_by( $groupby, $wp_query ) { global $wpdb; - $search = trim( $wp_query->get( 'search' ) ); - if ( $search && empty( $groupby ) ) { + $search = $wp_query->get( 'search' ); + $low_in_stock = $wp_query->get( 'low_in_stock' ); + if ( empty( $groupby ) && ( $search || $low_in_stock ) ) { $groupby = $wpdb->posts . '.ID'; } return $groupby; } - }