Commit 0a3a4a581d9 for woocommerce
commit 0a3a4a581d91812043aec85559aa2af24df400ac
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date: Fri Jul 10 12:03:17 2026 +0600
Combine Activity Panel count requests into one endpoint (#66276)
* perf(admin): combine activity panel count requests
- Add /wc-analytics/activity-panel/counts endpoint returning orders,
reviews, and low stock counts in one response
- Add activityPanelStore with getActivityPanelCounts selector
- Migrate header bell, abbreviated notifications, and homescreen
panels to the shared selector so they resolve to a single request
- Remove now-unused count helpers from orders/reviews utils
Opening a wp-admin page fired 6 separate wc-analytics requests just
to populate the Activity Panel. This combines them into one request
and gives every consumer the same cached resolution.
Fixes #60393
* fix(activity-panel): return null instead of 0 on sub-request failure
- get_count_via() returns null (not 0) when a sub-request errors, so a
merchant can't mistake "sub-request failed" for a genuine zero count
- get_item_schema() marks the three count fields nullable
- widen ActivityPanelCounts TS type to number | null
- add regression test forcing a sub-request failure
Addresses CodeRabbit feedback on PR #66276
* fix(activity-panel): address review feedback from chihsuan
- Add activityPanelStore invalidation in reviews/stock update flows
- Remove try/catch from resolver to allow retry on failure
- Remove unnecessary _embed param from reviews sub-request
- Remove dead is_wp_error() check (rest_do_request wraps errors)
- Fix schema nullable type to use union array instead of nullable flag
- Remove dead schema fields (context, readonly) not applied by endpoint
- Fix final fallback to return null instead of 0 for missing total
Refs #66276
* fix(activity-panel): resolve CI failures on PR #66276
- Add missing declare(strict_types=1) to ActivityPanelCounts.php and its test
- Fix equals-sign alignment in ActivityPanelCountsTest.php (PHPCS)
- Mock invalidateActivityPanel dispatch in StockPanel jest test (was crashing
after invalidateActivityPanel was wired into updateStock)
- Restore try/catch in getActivityPanelCounts resolver per chihsuan's review
request, with matching test coverage for the error dispatch path
Refs #66276
* fix(activity-panel): rename stock_status param to product_status for clarity
The stock_status param was misleading - it controls the product post
status (publish/draft), not WooCommerce stock status (instock/outofstock).
Rename to product_status to avoid confusing API consumers.
Addresses CodeRabbit review feedback.
Refs #66276
diff --git a/packages/js/data/changelog/add-60393-activity-panel-store b/packages/js/data/changelog/add-60393-activity-panel-store
new file mode 100644
index 00000000000..f2ffe2fb2b6
--- /dev/null
+++ b/packages/js/data/changelog/add-60393-activity-panel-store
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add activityPanelStore with a getActivityPanelCounts selector, backed by a single combined REST request.
diff --git a/packages/js/data/src/activity-panel/action-types.ts b/packages/js/data/src/activity-panel/action-types.ts
new file mode 100644
index 00000000000..b2e5b4d7d46
--- /dev/null
+++ b/packages/js/data/src/activity-panel/action-types.ts
@@ -0,0 +1,6 @@
+enum TYPES {
+ GET_ACTIVITY_PANEL_COUNTS_SUCCESS = 'GET_ACTIVITY_PANEL_COUNTS_SUCCESS',
+ GET_ACTIVITY_PANEL_COUNTS_ERROR = 'GET_ACTIVITY_PANEL_COUNTS_ERROR',
+}
+
+export default TYPES;
diff --git a/packages/js/data/src/activity-panel/actions.ts b/packages/js/data/src/activity-panel/actions.ts
new file mode 100644
index 00000000000..f294e44b879
--- /dev/null
+++ b/packages/js/data/src/activity-panel/actions.ts
@@ -0,0 +1,23 @@
+/**
+ * Internal dependencies
+ */
+import TYPES from './action-types';
+import { ActivityPanelCounts } from './types';
+
+export function getActivityPanelCountsSuccess( counts: ActivityPanelCounts ) {
+ return {
+ type: TYPES.GET_ACTIVITY_PANEL_COUNTS_SUCCESS as const,
+ counts,
+ };
+}
+
+export function getActivityPanelCountsError( error: unknown ) {
+ return {
+ type: TYPES.GET_ACTIVITY_PANEL_COUNTS_ERROR as const,
+ error,
+ };
+}
+
+export type Actions = ReturnType<
+ typeof getActivityPanelCountsSuccess | typeof getActivityPanelCountsError
+>;
diff --git a/packages/js/data/src/activity-panel/constants.ts b/packages/js/data/src/activity-panel/constants.ts
new file mode 100644
index 00000000000..8194dc5bdf9
--- /dev/null
+++ b/packages/js/data/src/activity-panel/constants.ts
@@ -0,0 +1 @@
+export const STORE_NAME = 'wc/admin/activity-panel' as const;
diff --git a/packages/js/data/src/activity-panel/index.ts b/packages/js/data/src/activity-panel/index.ts
new file mode 100644
index 00000000000..b844c07b395
--- /dev/null
+++ b/packages/js/data/src/activity-panel/index.ts
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { createReduxStore, register } from '@wordpress/data';
+import { controls } from '@wordpress/data-controls';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_NAME } from './constants';
+import * as selectors from './selectors';
+import * as actions from './actions';
+import * as resolvers from './resolvers';
+import reducer, { type State } from './reducer';
+
+export * from './types';
+export type { State };
+
+export const store = createReduxStore( STORE_NAME, {
+ reducer,
+ actions,
+ controls,
+ selectors,
+ resolvers,
+} );
+
+register( store );
+
+export const ACTIVITY_PANEL_STORE_NAME = STORE_NAME;
+
+declare module '@wordpress/data' {
+ interface StoreRegistry {
+ [ STORE_NAME ]: typeof store;
+ }
+}
diff --git a/packages/js/data/src/activity-panel/reducer.ts b/packages/js/data/src/activity-panel/reducer.ts
new file mode 100644
index 00000000000..915655b00cc
--- /dev/null
+++ b/packages/js/data/src/activity-panel/reducer.ts
@@ -0,0 +1,37 @@
+/**
+ * External dependencies
+ */
+import { Reducer } from 'redux';
+
+/**
+ * Internal dependencies
+ */
+import TYPES from './action-types';
+import { Actions } from './actions';
+import { ActivityPanelState } from './types';
+
+const reducer: Reducer< ActivityPanelState, Actions > = (
+ state = {},
+ payload
+) => {
+ if ( payload && 'type' in payload ) {
+ switch ( payload.type ) {
+ case TYPES.GET_ACTIVITY_PANEL_COUNTS_SUCCESS:
+ return {
+ ...state,
+ counts: payload.counts,
+ };
+ case TYPES.GET_ACTIVITY_PANEL_COUNTS_ERROR:
+ return {
+ ...state,
+ error: payload.error,
+ };
+ default:
+ return state;
+ }
+ }
+ return state;
+};
+
+export type State = ReturnType< typeof reducer >;
+export default reducer;
diff --git a/packages/js/data/src/activity-panel/resolvers.ts b/packages/js/data/src/activity-panel/resolvers.ts
new file mode 100644
index 00000000000..f472e279a35
--- /dev/null
+++ b/packages/js/data/src/activity-panel/resolvers.ts
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { apiFetch } from '@wordpress/data-controls';
+
+/**
+ * Internal dependencies
+ */
+import { NAMESPACE } from '../constants';
+import {
+ getActivityPanelCountsSuccess,
+ getActivityPanelCountsError,
+} from './actions';
+import { ActivityPanelCounts } from './types';
+
+/**
+ * Request the Activity Panel counts (orders to fulfill, reviews to moderate,
+ * low stock products) in a single request instead of one per count.
+ */
+export function* getActivityPanelCounts() {
+ try {
+ const counts: ActivityPanelCounts = yield apiFetch( {
+ path: `${ NAMESPACE }/activity-panel/counts`,
+ } );
+ yield getActivityPanelCountsSuccess( counts );
+ } catch ( error ) {
+ yield getActivityPanelCountsError( error );
+ }
+}
diff --git a/packages/js/data/src/activity-panel/selectors.ts b/packages/js/data/src/activity-panel/selectors.ts
new file mode 100644
index 00000000000..1e1c858c448
--- /dev/null
+++ b/packages/js/data/src/activity-panel/selectors.ts
@@ -0,0 +1,22 @@
+/**
+ * Internal dependencies
+ */
+import { ActivityPanelState } from './types';
+
+/**
+ * Get the Activity Panel counts from state tree.
+ *
+ * @param {Object} state - Reducer state
+ */
+export const getActivityPanelCounts = ( state: ActivityPanelState ) => {
+ return state.counts;
+};
+
+/**
+ * Determine if fetching the Activity Panel counts resulted in an error.
+ *
+ * @param {Object} state - Reducer state
+ */
+export const getActivityPanelCountsError = ( state: ActivityPanelState ) => {
+ return state.error;
+};
diff --git a/packages/js/data/src/activity-panel/test/reducer.ts b/packages/js/data/src/activity-panel/test/reducer.ts
new file mode 100644
index 00000000000..2a8e061350d
--- /dev/null
+++ b/packages/js/data/src/activity-panel/test/reducer.ts
@@ -0,0 +1,46 @@
+/**
+ * @jest-environment node
+ */
+
+/**
+ * Internal dependencies
+ */
+import reducer from '../reducer';
+import TYPES from '../action-types';
+
+describe( 'activity-panel reducer', () => {
+ it( 'should return a default state', () => {
+ // @ts-expect-error reducer action should not be empty but it is
+ const state = reducer( undefined, {} );
+ expect( state ).toEqual( {} );
+ } );
+
+ it( 'should handle GET_ACTIVITY_PANEL_COUNTS_SUCCESS', () => {
+ const counts = {
+ orders_to_fulfill_count: 2,
+ reviews_to_moderate_count: 1,
+ products_low_in_stock_count: 3,
+ };
+ const state = reducer(
+ {},
+ {
+ type: TYPES.GET_ACTIVITY_PANEL_COUNTS_SUCCESS,
+ counts,
+ }
+ );
+
+ expect( state.counts ).toEqual( counts );
+ } );
+
+ it( 'should handle GET_ACTIVITY_PANEL_COUNTS_ERROR', () => {
+ const state = reducer(
+ {},
+ {
+ type: TYPES.GET_ACTIVITY_PANEL_COUNTS_ERROR,
+ error: 'Something went wrong',
+ }
+ );
+
+ expect( state.error ).toBe( 'Something went wrong' );
+ } );
+} );
diff --git a/packages/js/data/src/activity-panel/test/resolvers.ts b/packages/js/data/src/activity-panel/test/resolvers.ts
new file mode 100644
index 00000000000..3b70daaeacc
--- /dev/null
+++ b/packages/js/data/src/activity-panel/test/resolvers.ts
@@ -0,0 +1,51 @@
+/**
+ * @jest-environment node
+ */
+
+/**
+ * External dependencies
+ */
+import { apiFetch } from '@wordpress/data-controls';
+
+/**
+ * Internal dependencies
+ */
+import { getActivityPanelCounts } from '../resolvers';
+import {
+ getActivityPanelCountsSuccess,
+ getActivityPanelCountsError,
+} from '../actions';
+import { NAMESPACE } from '../../constants';
+
+describe( 'getActivityPanelCounts resolver', () => {
+ it( 'fetches the combined counts and dispatches success', () => {
+ const generator = getActivityPanelCounts();
+
+ expect( generator.next().value ).toEqual(
+ apiFetch( { path: `${ NAMESPACE }/activity-panel/counts` } )
+ );
+
+ const counts = {
+ orders_to_fulfill_count: 2,
+ reviews_to_moderate_count: 1,
+ products_low_in_stock_count: 3,
+ };
+
+ expect( generator.next( counts ).value ).toEqual(
+ getActivityPanelCountsSuccess( counts )
+ );
+ expect( generator.next().done ).toBe( true );
+ } );
+
+ it( 'dispatches an error when the fetch fails', () => {
+ const generator = getActivityPanelCounts();
+ generator.next();
+
+ const error = new Error( 'Request failed' );
+
+ expect( generator.throw( error ).value ).toEqual(
+ getActivityPanelCountsError( error )
+ );
+ expect( generator.next().done ).toBe( true );
+ } );
+} );
diff --git a/packages/js/data/src/activity-panel/test/selectors.ts b/packages/js/data/src/activity-panel/test/selectors.ts
new file mode 100644
index 00000000000..e872189145f
--- /dev/null
+++ b/packages/js/data/src/activity-panel/test/selectors.ts
@@ -0,0 +1,33 @@
+/**
+ * @jest-environment node
+ */
+
+/**
+ * Internal dependencies
+ */
+import {
+ getActivityPanelCounts,
+ getActivityPanelCountsError,
+} from '../selectors';
+
+describe( 'activity-panel selectors', () => {
+ it( 'getActivityPanelCounts returns the counts from state', () => {
+ const counts = {
+ orders_to_fulfill_count: 2,
+ reviews_to_moderate_count: 1,
+ products_low_in_stock_count: 3,
+ };
+
+ expect( getActivityPanelCounts( { counts } ) ).toEqual( counts );
+ } );
+
+ it( 'getActivityPanelCounts returns undefined when not yet resolved', () => {
+ expect( getActivityPanelCounts( {} ) ).toBeUndefined();
+ } );
+
+ it( 'getActivityPanelCountsError returns the error from state', () => {
+ expect( getActivityPanelCountsError( { error: 'boom' } ) ).toBe(
+ 'boom'
+ );
+ } );
+} );
diff --git a/packages/js/data/src/activity-panel/types.ts b/packages/js/data/src/activity-panel/types.ts
new file mode 100644
index 00000000000..e9417db6506
--- /dev/null
+++ b/packages/js/data/src/activity-panel/types.ts
@@ -0,0 +1,27 @@
+/**
+ * Internal dependencies
+ */
+import { WPDataSelector, WPDataSelectors } from '../types';
+import {
+ getActivityPanelCounts,
+ getActivityPanelCountsError,
+} from './selectors';
+
+export type ActivityPanelCounts = {
+ // Null when the endpoint's underlying sub-request for this count failed.
+ orders_to_fulfill_count: number | null;
+ reviews_to_moderate_count: number | null;
+ products_low_in_stock_count: number | null;
+};
+
+export type ActivityPanelState = {
+ counts?: ActivityPanelCounts;
+ error?: unknown;
+};
+
+export type ActivityPanelSelectors = {
+ getActivityPanelCounts: WPDataSelector< typeof getActivityPanelCounts >;
+ getActivityPanelCountsError: WPDataSelector<
+ typeof getActivityPanelCountsError
+ >;
+} & WPDataSelectors;
diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts
index c0cc818a869..2e5fac1bd55 100644
--- a/packages/js/data/src/index.ts
+++ b/packages/js/data/src/index.ts
@@ -28,6 +28,7 @@ export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories
export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
+export { ACTIVITY_PANEL_STORE_NAME } from './activity-panel';
export type { PaymentGateway } from './payment-gateways/types';
export type {
PaymentsEntity,
@@ -79,6 +80,7 @@ export { store as woopaymentsOnboardingStore } from './woopayments-onboarding';
export { store as reportsStore } from './reports';
export { store as itemsStore } from './items';
export { store as experimentalSettingOptionsStore } from './setting-options';
+export { store as activityPanelStore } from './activity-panel';
// Export hooks
export { withSettingsHydration } from './settings/with-settings-hydration';
@@ -205,6 +207,7 @@ import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-
import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
import type { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form';
+import type { ACTIVITY_PANEL_STORE_NAME } from './activity-panel';
export type WCDataStoreName =
| typeof REVIEWS_STORE_NAME
@@ -232,7 +235,8 @@ export type WCDataStoreName =
| typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
| typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
- | typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME;
+ | typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
+ | typeof ACTIVITY_PANEL_STORE_NAME;
/**
* Internal dependencies
@@ -256,6 +260,7 @@ import { ProductVariationSelectors } from './product-variations/types';
import { TaxClassSelectors } from './tax-classes/types';
import { ProductFormSelectors } from './product-form/selectors';
import { WooPaymentsOnboardingSelectors } from './woopayments-onboarding/selectors';
+import { ActivityPanelSelectors } from './activity-panel/types';
// As we add types to all the package selectors we can fill out these unknown types with real ones. See one
// of the already typed selectors for an example of how you can do this.
@@ -311,6 +316,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
? TaxClassSelectors
: T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
? ProductFormSelectors
+ : T extends typeof ACTIVITY_PANEL_STORE_NAME
+ ? ActivityPanelSelectors
: never;
export interface WCDataSelector {
diff --git a/plugins/woocommerce/changelog/fix-60393-combine-activity-panel-count-requests b/plugins/woocommerce/changelog/fix-60393-combine-activity-panel-count-requests
new file mode 100644
index 00000000000..06dfb0066e9
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-60393-combine-activity-panel-count-requests
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Combine the Activity Panel's orders, reviews, and low stock count requests into a single wc-analytics/activity-panel/counts endpoint.
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js b/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
index 651a274a1a7..b4f3e2eac8d 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/activity-panel.js
@@ -17,7 +17,12 @@ import {
} from '@wordpress/icons';
import { STORE_KEY as CES_STORE_KEY } from '@woocommerce/customer-effort-score';
import { H, Section } from '@woocommerce/components';
-import { onboardingStore, optionsStore, useUser } from '@woocommerce/data';
+import {
+ activityPanelStore,
+ onboardingStore,
+ optionsStore,
+ useUser,
+} from '@woocommerce/data';
import { addHistoryListener } from '@woocommerce/navigation';
import { recordEvent } from '@woocommerce/tracks';
import { useSlot } from '@woocommerce/experimental';
@@ -34,12 +39,6 @@ import { hasUnreadNotes as checkIfHasUnreadNotes } from './unread-indicators';
import { Tabs } from './tabs';
import { DisplayOptions } from './display-options';
import { Panel } from './panel';
-import {
- getLowStockCount as getLowStockProducts,
- getOrderStatuses,
- getUnreadOrders,
-} from '../homescreen/activity-panel/orders/utils';
-import { getUnapprovedReviews } from '../homescreen/activity-panel/reviews/utils';
import { ABBREVIATED_NOTIFICATION_SLOT_NAME } from './panels/inbox/abbreviated-notifications-panel';
import { getAdminSetting } from '~/utils/admin-settings';
import { getUrlParams } from '~/utils';
@@ -129,16 +128,17 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
const checkIfHasAbbreviatedNotifications = useCallback(
( select, setupTaskListHidden, thingsToDoNextCount ) => {
- const orderStatuses = getOrderStatuses( select );
+ const counts =
+ select( activityPanelStore ).getActivityPanelCounts();
const isOrdersCardVisible = setupTaskListHidden
- ? getUnreadOrders( select, orderStatuses ) > 0
+ ? ( counts?.orders_to_fulfill_count ?? 0 ) > 0
: false;
const isReviewsCardVisible = setupTaskListHidden
- ? getUnapprovedReviews( select )
+ ? ( counts?.reviews_to_moderate_count ?? 0 ) > 0
: false;
const isLowStockCardVisible = setupTaskListHidden
- ? getLowStockProducts( select )
+ ? ( counts?.products_low_in_stock_count ?? 0 ) > 0
: false;
return (
diff --git a/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js b/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js
index e7f7d12d955..0f1ee4e12fe 100644
--- a/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js
+++ b/plugins/woocommerce/client/admin/client/activity-panel/panels/inbox/abbreviated-notifications-panel.js
@@ -9,16 +9,11 @@ import { useSelect } from '@wordpress/data';
import { box, comment, page } from '@wordpress/icons';
import { createSlotFill } from '@wordpress/components';
import { isWCAdmin } from '@woocommerce/navigation';
+import { activityPanelStore } from '@woocommerce/data';
/**
* Internal dependencies
*/
-import {
- getLowStockCount,
- getOrderStatuses,
- getUnreadOrders,
-} from '~/homescreen/activity-panel/orders/utils';
-import { getUnapprovedReviews } from '~/homescreen/activity-panel/reviews/utils';
import { Bell } from './icons/bell';
import { isTaskListVisible } from '~/hooks/use-tasklists-state';
@@ -40,12 +35,12 @@ export const AbbreviatedNotificationsPanel = ( { thingsToDoNextCount } ) => {
isSetupTaskListHidden,
isExtendedTaskListHidden,
} = useSelect( ( select ) => {
- const orderStatuses = getOrderStatuses( select );
+ const counts = select( activityPanelStore ).getActivityPanelCounts();
return {
- ordersToProcessCount: getUnreadOrders( select, orderStatuses ),
- reviewsToModerateCount: getUnapprovedReviews( select ),
- stockNoticesCount: getLowStockCount( select ),
+ ordersToProcessCount: counts?.orders_to_fulfill_count ?? 0,
+ reviewsToModerateCount: counts?.reviews_to_moderate_count ?? 0,
+ stockNoticesCount: counts?.products_low_in_stock_count ?? 0,
isSetupTaskListHidden: ! isTaskListVisible( 'setup' ),
isExtendedTaskListHidden: ! isTaskListVisible( 'extended' ),
};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js
index 71588d34aa0..a424e946277 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/index.js
@@ -10,7 +10,11 @@ import {
PanelRow,
__experimentalText as Text,
} from '@wordpress/components';
-import { ordersStore, productsStore } from '@woocommerce/data';
+import {
+ activityPanelStore,
+ ordersStore,
+ productsStore,
+} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useEffect } from '@wordpress/element';
import { snakeCase } from 'lodash';
@@ -19,13 +23,8 @@ import { snakeCase } from 'lodash';
* Internal dependencies
*/
import './style.scss';
-import {
- getLowStockCount,
- getOrderStatuses,
- getUnreadOrders,
-} from './orders/utils';
+import { getOrderStatuses } from './orders/utils';
import { getAllPanels } from './panels';
-import { getUnapprovedReviews } from './reviews/utils';
import { getUrlParams } from '../../utils';
import { getAdminSetting } from '~/utils/admin-settings';
import { isTaskListVisible } from '~/hooks/use-tasklists-state';
@@ -49,10 +48,13 @@ export const ActivityPanel = () => {
const totalOrderCount = getOrdersTotalCount( ORDERS_QUERY_PARAMS, 0 );
const orderStatuses = getOrderStatuses( select );
const reviewsEnabled = getAdminSetting( 'reviewsEnabled', 'no' );
- const unreadOrdersCount = getUnreadOrders( select, orderStatuses );
const manageStock = getAdminSetting( 'manageStock', 'no' );
- const lowStockProductsCount = getLowStockCount( select );
- const unapprovedReviewsCount = getUnapprovedReviews( select );
+ const counts = select( activityPanelStore ).getActivityPanelCounts();
+ const unreadOrdersCount = counts?.orders_to_fulfill_count ?? null;
+ const lowStockProductsCount =
+ counts?.products_low_in_stock_count ?? null;
+ const unapprovedReviewsCount =
+ counts?.reviews_to_moderate_count ?? null;
const publishedProductCount = getProductsTotalCount(
PUBLISHED_PRODUCTS_QUERY_PARAMS,
0
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js
index add25bdb2d9..16b477b555c 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/orders/utils.js
@@ -1,52 +1,13 @@
/**
* External dependencies
*/
-import { settingsStore, itemsStore } from '@woocommerce/data';
+import { settingsStore } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { DEFAULT_ACTIONABLE_STATUSES } from '../../../analytics/settings/config';
-export function getUnreadOrders( select, orderStatuses ) {
- if ( ! orderStatuses.length ) {
- return 0;
- }
-
- const ordersQuery = {
- page: 1,
- per_page: 1, // Core endpoint requires per_page > 0.
- status: orderStatuses,
- _fields: [ 'id' ],
- };
-
- const defaultValue = null;
-
- const { getItemsTotalCount, getItemsError, isResolving } =
- select( itemsStore );
-
- // Disable eslint rule requiring `totalOrders` to be defined below because the next two statements
- // depend on `getItemsTotalCount` to have been called.
- // eslint-disable-next-line @wordpress/no-unused-vars-before-return
- const totalOrders = getItemsTotalCount(
- 'orders',
- ordersQuery,
- defaultValue
- );
- const isError = Boolean( getItemsError( 'orders', ordersQuery ) );
- const isRequesting = isResolving( 'getItemsTotalCount', [
- 'orders',
- ordersQuery,
- defaultValue,
- ] );
-
- if ( isError || isRequesting ) {
- return null;
- }
-
- return totalOrders;
-}
-
export function getOrderStatuses( select ) {
const { getSetting: getMutableSetting } = select( settingsStore );
const {
@@ -56,40 +17,8 @@ export function getOrderStatuses( select ) {
return orderStatuses;
}
+// Still used by the Stock panel's own low-stock list fetch — the count
+// itself now comes from activityPanelStore's getActivityPanelCounts().
export const getLowStockCountQuery = {
status: 'publish',
};
-
-export function getLowStockCount( select ) {
- const { getItemsTotalCount, getItemsError, isResolving } =
- select( itemsStore );
-
- const defaultValue = null;
-
- // Disable eslint rule requiring `totalLowStockProducts` to be defined below because the next two statements
- // depend on `getItemsTotalCount` to have been called.
- // eslint-disable-next-line @wordpress/no-unused-vars-before-return
- const totalLowStockProducts = getItemsTotalCount(
- 'products/count-low-in-stock',
- getLowStockCountQuery,
- defaultValue
- );
-
- const isError = Boolean(
- getItemsError( 'products/count-low-in-stock', getLowStockCountQuery )
- );
- const isRequesting = isResolving( 'getItemsTotalCount', [
- 'products/count-low-in-stock',
- getLowStockCountQuery,
- defaultValue,
- ] );
-
- if (
- isError ||
- ( isRequesting && totalLowStockProducts === defaultValue )
- ) {
- return null;
- }
-
- return totalLowStockProducts;
-}
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js
index 4f6ca2db57e..6504793b2f5 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/index.js
@@ -19,7 +19,7 @@ import {
} from '@woocommerce/components';
import { getAdminLink } from '@woocommerce/settings';
import { get, isNull } from 'lodash';
-import { reviewsStore } from '@woocommerce/data';
+import { activityPanelStore, reviewsStore } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { CurrencyContext } from '@woocommerce/currency';
@@ -362,16 +362,21 @@ export default compose( [
isRequesting,
};
} ),
- withDispatch( ( dispatch ) => {
+ withDispatch( ( dispatch, props ) => {
const { deleteReview, updateReview, invalidateResolution } =
dispatch( reviewsStore );
+ const { invalidateResolution: invalidateActivityPanel } =
+ dispatch( activityPanelStore );
const { createNotice } = dispatch( 'core/notices' );
const clearReviewsCache = () => {
invalidateResolution( 'getReviews', [ reviewsQuery ] );
- invalidateResolution( 'getReviewsTotalCount', [
- unapprovedReviewsQuery,
- ] );
+ if ( props.reviews && props.reviews.length < 2 ) {
+ invalidateResolution( 'getReviewsTotalCount', [
+ unapprovedReviewsQuery,
+ ] );
+ }
+ invalidateActivityPanel( 'getActivityPanelCounts', [] );
};
return {
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js
index 558c10f63ad..307fcb4b373 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/reviews/utils.js
@@ -1,10 +1,7 @@
-/**
- * External dependencies
- */
-import { reviewsStore } from '@woocommerce/data';
-
export const REVIEW_PAGE_LIMIT = 5;
+// Still used for the Reviews panel's own cache invalidation — the count
+// itself now comes from activityPanelStore's getActivityPanelCounts().
export const unapprovedReviewsQuery = {
page: 1,
per_page: 1,
@@ -12,20 +9,3 @@ export const unapprovedReviewsQuery = {
_embed: 1,
_fields: [ 'id' ],
};
-export function getUnapprovedReviews( select ) {
- const { getReviewsTotalCount, getReviewsError, isResolving } =
- select( reviewsStore );
-
- // eslint-disable-next-line @wordpress/no-unused-vars-before-return
- const totalReviews = getReviewsTotalCount( unapprovedReviewsQuery );
- const isError = Boolean( getReviewsError( unapprovedReviewsQuery ) );
- const isRequesting = isResolving( 'getReviewsTotalCount', [
- unapprovedReviewsQuery,
- ] );
-
- if ( isError || ( isRequesting && totalReviews === undefined ) ) {
- return null;
- }
-
- return totalReviews;
-}
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js
index 50c75b06f97..ab20770c2e9 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/index.js
@@ -6,7 +6,7 @@ import { compose } from '@wordpress/compose';
import { withDispatch, withSelect } from '@wordpress/data';
import PropTypes from 'prop-types';
import { Section } from '@woocommerce/components';
-import { itemsStore } from '@woocommerce/data';
+import { activityPanelStore, itemsStore } from '@woocommerce/data';
/**
* Internal dependencies
@@ -39,7 +39,11 @@ export class StockPanel extends Component {
}
async updateStock( product, quantity ) {
- const { invalidateResolution, updateProductStock } = this.props;
+ const {
+ invalidateResolution,
+ invalidateActivityPanel,
+ updateProductStock,
+ } = this.props;
const success = await updateProductStock( product, quantity );
if ( success ) {
@@ -53,6 +57,7 @@ export class StockPanel extends Component {
getLowStockCountQuery,
null,
] );
+ invalidateActivityPanel( 'getActivityPanelCounts', [] );
}
return success;
@@ -135,11 +140,14 @@ export default compose(
withDispatch( ( dispatch ) => {
const { invalidateResolution, updateProductStock } =
dispatch( itemsStore );
+ const { invalidateResolution: invalidateActivityPanel } =
+ dispatch( activityPanelStore );
const { createNotice } = dispatch( 'core/notices' );
return {
createNotice,
invalidateResolution,
+ invalidateActivityPanel,
updateProductStock,
};
} )
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js
index 81b5dc11bc9..c01a2b62fef 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/stock/test/index.js
@@ -32,6 +32,7 @@ describe( 'StockPanel', () => {
it( 'should request more products when one is updated', async () => {
const createNotice = jest.fn();
const invalidateResolution = jest.fn();
+ const invalidateActivityPanel = jest.fn();
const updateProductStock = jest.fn().mockResolvedValue( true );
const { getByRole } = render(
@@ -49,6 +50,7 @@ describe( 'StockPanel', () => {
},
] }
invalidateResolution={ invalidateResolution }
+ invalidateActivityPanel={ invalidateActivityPanel }
updateProductStock={ updateProductStock }
createNotice={ createNotice }
/>
@@ -62,6 +64,10 @@ describe( 'StockPanel', () => {
await waitFor( () => {
expect( invalidateResolution ).toHaveBeenCalled();
} );
+ expect( invalidateActivityPanel ).toHaveBeenCalledWith(
+ 'getActivityPanelCounts',
+ []
+ );
} );
it( 'should record activity_panel_stock_update_stock Tracks event when Update stock is clicked', async () => {
const createNotice = jest.fn();
diff --git a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js
index b71d85e3701..bfbafa936c7 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js
+++ b/plugins/woocommerce/client/admin/client/homescreen/activity-panel/test/index.js
@@ -47,11 +47,9 @@ jest.mock( '../panels', () => {
};
} );
-// Mock the orders and order statuses.
+// Mock the order statuses.
jest.mock( '../orders/utils', () => {
return {
- getLowStockCount: jest.fn().mockImplementation( () => 0 ),
- getUnreadOrders: jest.fn().mockImplementation( () => 100 ),
getOrderStatuses: jest.fn().mockImplementation( () => [ 'status' ] ),
};
} );
diff --git a/plugins/woocommerce/src/Admin/API/ActivityPanelCounts.php b/plugins/woocommerce/src/Admin/API/ActivityPanelCounts.php
new file mode 100644
index 00000000000..519d6f07924
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/API/ActivityPanelCounts.php
@@ -0,0 +1,196 @@
+<?php
+/**
+ * REST API ActivityPanelCounts Controller
+ *
+ * Handles requests to /activity-panel/counts.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\API;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * ActivityPanelCounts controller.
+ *
+ * @internal
+ */
+class ActivityPanelCounts extends \WC_REST_Data_Controller {
+
+ /**
+ * Endpoint namespace.
+ *
+ * @var string
+ */
+ protected $namespace = 'wc-analytics';
+
+ /**
+ * Route base.
+ *
+ * @var string
+ */
+ protected $rest_base = 'activity-panel/counts';
+
+ /**
+ * Register routes.
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_counts' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_counts_params(),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Return the orders/reviews/low-stock counts used by the Activity Panel in one response,
+ * instead of one request per count.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+ * @return \WP_REST_Response
+ */
+ public function get_counts( $request ) {
+ return rest_ensure_response(
+ array(
+ 'orders_to_fulfill_count' => $this->get_count_via(
+ '/wc-analytics/orders',
+ array(
+ 'page' => 1,
+ 'per_page' => 1,
+ 'status' => $request->get_param( 'order_statuses' ),
+ '_fields' => array( 'id' ),
+ )
+ ),
+ 'reviews_to_moderate_count' => $this->get_count_via(
+ '/wc-analytics/products/reviews',
+ array(
+ 'page' => 1,
+ 'per_page' => 1,
+ 'status' => $request->get_param( 'review_status' ),
+ '_fields' => array( 'id' ),
+ )
+ ),
+ 'products_low_in_stock_count' => $this->get_count_via(
+ '/wc-analytics/products/count-low-in-stock',
+ array( 'status' => $request->get_param( 'product_status' ) )
+ ),
+ )
+ );
+ }
+
+ /**
+ * Run one of the existing count endpoints internally and read its total off the response,
+ * so the counting logic itself (query building, permission checks) isn't duplicated here.
+ *
+ * @param string $route REST route to call, e.g. '/wc-analytics/orders'.
+ * @param array $params Query params for the sub-request.
+ * @return int|null Null when the sub-request failed, so callers can tell "unknown" apart from a real zero count.
+ */
+ private function get_count_via( $route, $params ) {
+ $sub_request = new \WP_REST_Request( 'GET', $route );
+ foreach ( $params as $key => $value ) {
+ $sub_request->set_param( $key, $value );
+ }
+
+ $response = rest_do_request( $sub_request );
+
+ if ( $response->is_error() ) {
+ wc_get_logger()->warning(
+ sprintf( 'Activity Panel counts sub-request to %s failed.', $route ),
+ array( 'source' => 'activity-panel-counts' )
+ );
+ return null;
+ }
+
+ $headers = $response->get_headers();
+ if ( isset( $headers['X-WP-Total'] ) ) {
+ return (int) $headers['X-WP-Total'];
+ }
+
+ $data = $response->get_data();
+ return isset( $data['total'] ) ? (int) $data['total'] : null;
+ }
+
+ /**
+ * Get the query params for the /activity-panel/counts endpoint.
+ *
+ * @return array
+ */
+ public function get_counts_params() {
+ $params = array();
+ $params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
+ $params['order_statuses'] = array(
+ 'description' => __( 'Order statuses counted as "to fulfill".', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ 'default' => $this->get_default_order_statuses(),
+ 'sanitize_callback' => 'wp_parse_list',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+ $params['review_status'] = array(
+ 'description' => __( 'Review status counted as "to moderate".', 'woocommerce' ),
+ 'type' => 'string',
+ 'default' => 'hold',
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+ $params['product_status'] = array(
+ 'description' => __( 'Product post status used for the low stock count.', 'woocommerce' ),
+ 'type' => 'string',
+ 'default' => 'publish',
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ return $params;
+ }
+
+ /**
+ * Get the default order statuses counted as "to fulfill", matching the store's own
+ * actionable order statuses setting.
+ *
+ * @return array
+ */
+ private function get_default_order_statuses() {
+ $actionable = get_option( 'woocommerce_actionable_order_statuses', array() );
+ return is_array( $actionable ) && ! empty( $actionable ) ? $actionable : array( 'processing', 'on-hold' );
+ }
+
+ /**
+ * Get the schema for the /activity-panel/counts response.
+ *
+ * @return array
+ */
+ public function get_item_schema() {
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'activity_panel_counts',
+ 'type' => 'object',
+ 'properties' => array(
+ 'orders_to_fulfill_count' => array(
+ 'description' => __( 'Number of orders to fulfill. Null if the underlying sub-request failed.', 'woocommerce' ),
+ 'type' => array( 'integer', 'null' ),
+ ),
+ 'reviews_to_moderate_count' => array(
+ 'description' => __( 'Number of reviews awaiting moderation. Null if the underlying sub-request failed.', 'woocommerce' ),
+ 'type' => array( 'integer', 'null' ),
+ ),
+ 'products_low_in_stock_count' => array(
+ 'description' => __( 'Number of products low in stock. Null if the underlying sub-request failed.', 'woocommerce' ),
+ 'type' => array( 'integer', 'null' ),
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $schema );
+ }
+}
diff --git a/plugins/woocommerce/src/Admin/API/Init.php b/plugins/woocommerce/src/Admin/API/Init.php
index 1de3bd82324..95050d88e54 100644
--- a/plugins/woocommerce/src/Admin/API/Init.php
+++ b/plugins/woocommerce/src/Admin/API/Init.php
@@ -153,6 +153,7 @@ class Init {
'Automattic\WooCommerce\Admin\API\ProductVariations',
'Automattic\WooCommerce\Admin\API\ProductReviews',
'Automattic\WooCommerce\Admin\API\ProductsLowInStock',
+ 'Automattic\WooCommerce\Admin\API\ActivityPanelCounts',
'Automattic\WooCommerce\Admin\API\SettingOptions',
'Automattic\WooCommerce\Admin\API\Taxes',
);
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/ActivityPanelCountsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/ActivityPanelCountsTest.php
new file mode 100644
index 00000000000..b27469fec97
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/ActivityPanelCountsTest.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * Test the API controller class that handles the /activity-panel/counts REST response.
+ *
+ * @package WooCommerce\Admin\Tests\Admin\API
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use WC_Helper_Order;
+use WC_Helper_Product;
+use WC_REST_Unit_Test_Case;
+use WP_Error;
+use WP_REST_Request;
+
+/**
+ * ActivityPanelCounts API controller test.
+ *
+ * @class ActivityPanelCountsTest.
+ */
+class ActivityPanelCountsTest extends WC_REST_Unit_Test_Case {
+
+ /**
+ * Endpoint.
+ *
+ * @var string
+ */
+ const ENDPOINT = '/wc-analytics/activity-panel/counts';
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->user = $this->factory->user->create(
+ array(
+ 'role' => 'administrator',
+ )
+ );
+ }
+
+ /**
+ * Create an unapproved (hold) product review.
+ *
+ * @param int $product_id Product ID.
+ * @return int Comment ID.
+ */
+ private function create_unapproved_review( $product_id ) {
+ return wp_insert_comment(
+ array(
+ 'comment_post_ID' => $product_id,
+ 'comment_author' => 'shopper',
+ 'comment_author_email' => 'shopper@example.com',
+ 'comment_content' => 'Awaiting moderation.',
+ 'comment_approved' => 0,
+ 'comment_type' => 'review',
+ )
+ );
+ }
+
+ /**
+ * Test that the response has the expected shape and counts for a store manager.
+ */
+ public function test_returns_counts_for_manager() {
+ wp_set_current_user( $this->user );
+
+ WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::PROCESSING ) );
+ WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::ON_HOLD ) );
+ WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::COMPLETED ) );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_low_stock_amount( 2 );
+ $product->set_stock_quantity( 1 );
+ $product->save();
+
+ $this->create_unapproved_review( $product->get_id() );
+
+ $request = new WP_REST_Request( 'GET', self::ENDPOINT );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 2, $data['orders_to_fulfill_count'] );
+ $this->assertEquals( 1, $data['reviews_to_moderate_count'] );
+ $this->assertEquals( 1, $data['products_low_in_stock_count'] );
+ }
+
+ /**
+ * Test that a user without manage_woocommerce is denied.
+ */
+ public function test_permission_denied_for_user_without_manage_woocommerce() {
+ $subscriber = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber );
+
+ $request = new WP_REST_Request( 'GET', self::ENDPOINT );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 403, $response->get_status() );
+ }
+
+ /**
+ * Test that a custom order_statuses param is respected instead of the default.
+ */
+ public function test_custom_order_statuses_param_is_respected() {
+ wp_set_current_user( $this->user );
+
+ WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::PROCESSING ) );
+ WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::ON_HOLD ) );
+
+ $request = new WP_REST_Request( 'GET', self::ENDPOINT );
+ $request->set_param( 'order_statuses', array( OrderStatus::PROCESSING ) );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 1, $data['orders_to_fulfill_count'] );
+ }
+
+ /**
+ * Test that the low stock count matches the existing dedicated endpoint, i.e. this
+ * controller isn't diverging from the counting logic it delegates to.
+ */
+ public function test_low_stock_count_matches_dedicated_endpoint() {
+ wp_set_current_user( $this->user );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_low_stock_amount( 5 );
+ $product->set_stock_quantity( 3 );
+ $product->save();
+
+ $counts_request = new WP_REST_Request( 'GET', self::ENDPOINT );
+ $counts_response = $this->server->dispatch( $counts_request );
+ $counts_data = $counts_response->get_data();
+
+ $dedicated_request = new WP_REST_Request( 'GET', '/wc-analytics/products/count-low-in-stock' );
+ $dedicated_request->set_param( 'status', ProductStatus::PUBLISH );
+ $dedicated_response = $this->server->dispatch( $dedicated_request );
+ $dedicated_data = $dedicated_response->get_data();
+
+ $this->assertEquals(
+ $dedicated_data['total'],
+ $counts_data['products_low_in_stock_count']
+ );
+ }
+
+ /**
+ * Test that a failed sub-request yields null for that count, not 0, so a merchant can't
+ * mistake "sub-request failed" for a genuine zero count.
+ */
+ public function test_failed_sub_request_returns_null_not_zero() {
+ wp_set_current_user( $this->user );
+
+ WC_Helper_Order::create_order( 1, null, array( 'status' => OrderStatus::PROCESSING ) );
+
+ $failing_route = '/wc-analytics/products/count-low-in-stock';
+ $force_error = function ( $result, $server, $request ) use ( $failing_route ) {
+ if ( $request->get_route() === $failing_route ) {
+ return new WP_Error( 'test_forced_failure', 'Forced failure for test.', array( 'status' => 500 ) );
+ }
+ return $result;
+ };
+
+ add_filter( 'rest_pre_dispatch', $force_error, 10, 3 );
+
+ try {
+ $request = new WP_REST_Request( 'GET', self::ENDPOINT );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+ } finally {
+ remove_filter( 'rest_pre_dispatch', $force_error, 10 );
+ }
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertNull( $data['products_low_in_stock_count'] );
+ $this->assertEquals( 1, $data['orders_to_fulfill_count'] );
+ }
+}