From 0df41cc12885b63372bcb977972da698f3265e77 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Sat, 4 Oct 2025 15:26:05 +0300 Subject: [PATCH 1/2] Forms: Replace 3 count queries with single optimized endpoint Reduces from 3 separate REST requests to 1 optimized database query with caching. Related: https://github.com/Automattic/jetpack/pull/45339 --- .../forms/changelog/optimize-inbox-counts | 4 + .../class-contact-form-endpoint.php | 102 ++++++++++++++++++ .../src/dashboard/hooks/use-inbox-data.ts | 78 +++++++------- .../src/dashboard/inbox/dataviews/index.js | 18 ++-- .../forms/src/dashboard/store/reducer.js | 3 +- 5 files changed, 156 insertions(+), 49 deletions(-) create mode 100644 projects/packages/forms/changelog/optimize-inbox-counts diff --git a/projects/packages/forms/changelog/optimize-inbox-counts b/projects/packages/forms/changelog/optimize-inbox-counts new file mode 100644 index 0000000000000..f473039d43b99 --- /dev/null +++ b/projects/packages/forms/changelog/optimize-inbox-counts @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Forms: replace 3 separate count queries with single optimized counts endpoint. diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index f1df3e65f0811..160ef2333be52 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -277,6 +277,39 @@ public function register_routes() { ), ) ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/counts', + array( + 'methods' => \WP_REST_Server::READABLE, + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'callback' => array( $this, 'get_status_counts' ), + 'args' => array( + 'search' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'parent' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'sanitize_callback' => function ( $value ) { + return array_map( 'absint', (array) $value ); + }, + ), + 'before' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'after' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); } /** @@ -325,6 +358,75 @@ static function ( $post_id ) { ); } + /** + * Retrieves status counts for inbox, spam, and trash in a single optimized query. + * + * @param WP_REST_Request $request Full data about the request. + * @return WP_REST_Response Response object on success. + */ + public function get_status_counts( $request ) { + global $wpdb; + + $search = $request->get_param( 'search' ); + $parent = $request->get_param( 'parent' ); + $before = $request->get_param( 'before' ); + $after = $request->get_param( 'after' ); + + $cache_key = 'jetpack_forms_status_counts_' . md5( wp_json_encode( compact( 'search', 'parent', 'before', 'after' ) ) ); + $cached_result = get_transient( $cache_key ); + if ( false !== $cached_result ) { + return rest_ensure_response( $cached_result ); + } + + $where_conditions = array( $wpdb->prepare( 'post_type = %s', 'feedback' ) ); + $join_clauses = ''; + + if ( ! empty( $search ) ) { + $search_like = '%' . $wpdb->esc_like( $search ) . '%'; + $where_conditions[] = $wpdb->prepare( '(post_title LIKE %s OR post_content LIKE %s)', $search_like, $search_like ); + } + + if ( ! empty( $parent ) && is_array( $parent ) ) { + $parent_ids = array_map( 'absint', $parent ); + $parent_ids_string = implode( ',', $parent_ids ); + $where_conditions[] = "post_parent IN ($parent_ids_string)"; + } + + if ( ! empty( $before ) || ! empty( $after ) ) { + if ( ! empty( $before ) ) { + $where_conditions[] = $wpdb->prepare( 'post_date <= %s', $before ); + } + if ( ! empty( $after ) ) { + $where_conditions[] = $wpdb->prepare( 'post_date >= %s', $after ); + } + } + + $where_clause = implode( ' AND ', $where_conditions ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $counts = $wpdb->get_row( + "SELECT + SUM(CASE WHEN post_status IN ('publish', 'draft') THEN 1 ELSE 0 END) as inbox, + SUM(CASE WHEN post_status = 'spam' THEN 1 ELSE 0 END) as spam, + SUM(CASE WHEN post_status = 'trash' THEN 1 ELSE 0 END) as trash + FROM $wpdb->posts + $join_clauses + WHERE $where_clause", + ARRAY_A + ); + // phpcs:enable + + $result = array( + 'inbox' => (int) ( $counts['inbox'] ?? 0 ), + 'spam' => (int) ( $counts['spam'] ?? 0 ), + 'trash' => (int) ( $counts['trash'] ?? 0 ), + ); + + set_transient( $cache_key, $result, 30 ); + + return rest_ensure_response( $result ); + } + /** * Adds the additional fields to the item's schema. * diff --git a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts index e0845ece7707e..9692351f963cc 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts @@ -1,8 +1,12 @@ /** * External dependencies */ + +import apiFetch from '@wordpress/api-fetch'; import { useEntityRecords, store as coreDataStore } from '@wordpress/core-data'; import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect, useState } from '@wordpress/element'; +import { addQueryArgs } from '@wordpress/url'; import { useSearchParams } from 'react-router'; /** * Internal dependencies @@ -36,6 +40,7 @@ interface UseInboxDataReturn { totalItemsTrash: number; records: FormResponse[]; isLoadingData: boolean; + isLoadingCounts: boolean; totalItems: number; totalPages: number; selectedResponsesCount: number; @@ -110,52 +115,43 @@ export default function useInboxData(): UseInboxDataReturn { [ rawRecords ] ); - const { isResolving: isLoadingInboxData, totalItems: totalItemsInbox = 0 } = useEntityRecords( - 'postType', - 'feedback', - { - page: 1, - search: '', - ...currentQuery, - status: 'publish,draft', - per_page: 1, - _fields: 'id', - } - ); + const [ counts, setCounts ] = useState( { inbox: 0, spam: 0, trash: 0 } ); + const [ isLoadingCounts, setIsLoadingCounts ] = useState( false ); - const { isResolving: isLoadingSpamData, totalItems: totalItemsSpam = 0 } = useEntityRecords( - 'postType', - 'feedback', - { - page: 1, - search: '', - ...currentQuery, - status: 'spam', - per_page: 1, - _fields: 'id', - } - ); + useEffect( () => { + const fetchCounts = async () => { + setIsLoadingCounts( true ); + const params: Record< string, unknown > = {}; + if ( currentQuery?.search ) { + params.search = currentQuery.search; + } + if ( currentQuery?.parent ) { + params.parent = currentQuery.parent; + } + if ( currentQuery?.before ) { + params.before = currentQuery.before; + } + if ( currentQuery?.after ) { + params.after = currentQuery.after; + } + const path = addQueryArgs( '/wp/v2/feedback/counts', params ); + const response = await apiFetch< { inbox: number; spam: number; trash: number } >( { + path, + } ); + setCounts( response ); + setIsLoadingCounts( false ); + }; - const { isResolving: isLoadingTrashData, totalItems: totalItemsTrash = 0 } = useEntityRecords( - 'postType', - 'feedback', - { - page: 1, - search: '', - ...currentQuery, - status: 'trash', - per_page: 1, - _fields: 'id', - } - ); + fetchCounts(); + }, [ currentQuery?.search, currentQuery?.parent, currentQuery?.before, currentQuery?.after ] ); return { - totalItemsInbox, - totalItemsSpam, - totalItemsTrash, + totalItemsInbox: counts.inbox, + totalItemsSpam: counts.spam, + totalItemsTrash: counts.trash, records, - isLoadingData: - isLoadingRecordsData || isLoadingInboxData || isLoadingSpamData || isLoadingTrashData, + isLoadingData: isLoadingRecordsData, + isLoadingCounts, totalItems, totalPages, selectedResponsesCount, diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index 7fd48a1f57043..b68359c4109b9 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -122,7 +122,7 @@ export default function InboxView() { totalPages, } = useInboxData(); - useEffect( () => { + const queryArgs = useMemo( () => { const _filters = view.filters?.reduce( ( accumulator, { field, value } ) => { if ( ! value ) { return accumulator; @@ -137,17 +137,21 @@ export default function InboxView() { } return accumulator; }, {} ); - const _queryArgs = { + const args = { per_page: view.perPage, page: view.page, - search: view.search, ..._filters, status: statusFilter, }; - // We need to keep the current query args in the store to be used in `export` - // and for getting the total records per `status`. - setCurrentQuery( _queryArgs ); - }, [ view, statusFilter, setCurrentQuery ] ); + if ( view.search ) { + args.search = view.search; + } + return args; + }, [ view.perPage, view.page, view.search, view.filters, statusFilter ] ); + + useEffect( () => { + setCurrentQuery( queryArgs ); + }, [ queryArgs, setCurrentQuery ] ); const data = useMemo( () => records?.map( record => ( { diff --git a/projects/packages/forms/src/dashboard/store/reducer.js b/projects/packages/forms/src/dashboard/store/reducer.js index e04f4b370991a..078860290cf4b 100644 --- a/projects/packages/forms/src/dashboard/store/reducer.js +++ b/projects/packages/forms/src/dashboard/store/reducer.js @@ -2,6 +2,7 @@ * External dependencies */ import { combineReducers } from '@wordpress/data'; +import { isEqual } from 'lodash'; /** * Internal dependencies */ @@ -16,7 +17,7 @@ const filters = ( state = {}, action ) => { const currentQuery = ( state = {}, action ) => { if ( action.type === SET_CURRENT_QUERY ) { - return action.currentQuery; + return isEqual( state, action.currentQuery ) ? state : action.currentQuery; } return state; }; From 454ddbf305f30c353078ea279ffd9f8bca3283a0 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 8 Oct 2025 16:41:28 -0300 Subject: [PATCH 2/2] Forms: Optimize counts with caching and shared state --- .../class-contact-form-endpoint.php | 53 +++++++++++++++++-- .../src/dashboard/hooks/use-inbox-data.ts | 33 +++++++++--- .../src/dashboard/inbox/dataviews/index.js | 35 +++++++++--- .../forms/src/dashboard/store/action-types.js | 1 + .../forms/src/dashboard/store/actions.js | 14 +++++ .../forms/src/dashboard/store/reducer.js | 15 +++++- .../forms/src/dashboard/store/selectors.js | 4 ++ 7 files changed, 136 insertions(+), 19 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index 160ef2333be52..d6518d3f83c56 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -364,6 +364,22 @@ static function ( $post_id ) { * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response Response object on success. */ + /** + * Clears the cached status counts for the default view. + * Called when feedback items are created, updated, or deleted. + */ + private function clear_status_counts_cache() { + delete_transient( 'jetpack_forms_status_counts_default' ); + } + + /** + * Get status counts for feedback items. + * Returns inbox, spam, and trash counts with optional filtering. + * Only caches the default view (no filters) for performance. + * + * @param WP_REST_Request $request Full data about the request. + * @return WP_REST_Response Response object on success. + */ public function get_status_counts( $request ) { global $wpdb; @@ -372,10 +388,12 @@ public function get_status_counts( $request ) { $before = $request->get_param( 'before' ); $after = $request->get_param( 'after' ); - $cache_key = 'jetpack_forms_status_counts_' . md5( wp_json_encode( compact( 'search', 'parent', 'before', 'after' ) ) ); - $cached_result = get_transient( $cache_key ); - if ( false !== $cached_result ) { - return rest_ensure_response( $cached_result ); + $is_default_view = empty( $search ) && empty( $parent ) && empty( $before ) && empty( $after ); + if ( $is_default_view ) { + $cached_result = get_transient( 'jetpack_forms_status_counts_default' ); + if ( false !== $cached_result ) { + return rest_ensure_response( $cached_result ); + } } $where_conditions = array( $wpdb->prepare( 'post_type = %s', 'feedback' ) ); @@ -422,7 +440,9 @@ public function get_status_counts( $request ) { 'trash' => (int) ( $counts['trash'] ?? 0 ), ); - set_transient( $cache_key, $result, 30 ); + if ( $is_default_view ) { + set_transient( 'jetpack_forms_status_counts_default', $result, 30 ); + } return rest_ensure_response( $result ); } @@ -628,6 +648,21 @@ public function get_item_schema() { return $this->add_additional_fields_schema( $this->schema ); } + /** + * Deletes the item. + * Overrides the parent method to clear cached counts when an item is deleted. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + $result = parent::delete_item( $request ); + if ( ! is_wp_error( $result ) ) { + $this->clear_status_counts_cache(); + } + return $result; + } + /** * Updates the item. * Overrides the parent method to resend the email when the item is updated from spam to publish. @@ -653,6 +688,8 @@ public function update_item( $request ) { do_action( 'contact_form_akismet', 'ham', $akismet_values ); $this->resend_email( $post_id ); } + // Clear cached counts when status changes + $this->clear_status_counts_cache(); } return $updated_item; } @@ -874,6 +911,10 @@ public function delete_posts_by_status( $request ) { //phpcs:ignore VariableAnal ++$deleted; } + if ( $deleted > 0 ) { + $this->clear_status_counts_cache(); + } + return new WP_REST_Response( array( 'deleted' => $deleted ), 200 ); } @@ -892,6 +933,7 @@ private function bulk_action_mark_as_spam( $post_ids ) { get_post_meta( $post_id, '_feedback_akismet_values', true ) ); } + $this->clear_status_counts_cache(); return new WP_REST_Response( array(), 200 ); } @@ -910,6 +952,7 @@ private function bulk_action_mark_as_not_spam( $post_ids ) { get_post_meta( $post_id, '_feedback_akismet_values', true ) ); } + $this->clear_status_counts_cache(); return new WP_REST_Response( array(), 200 ); } diff --git a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts index 9692351f963cc..d258b4f29cd2e 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts @@ -50,6 +50,7 @@ interface UseInboxDataReturn { currentQuery: Record< string, unknown >; setCurrentQuery: ( query: Record< string, unknown > ) => void; filterOptions: Record< string, unknown >; + updateCountsOptimistically: ( fromStatus: string, toStatus: string, count?: number ) => void; } const RESPONSE_FIELDS = [ @@ -75,16 +76,28 @@ const RESPONSE_FIELDS = [ */ export default function useInboxData(): UseInboxDataReturn { const [ searchParams ] = useSearchParams(); - const { setCurrentQuery, setSelectedResponses } = useDispatch( dashboardStore ); + const { setCurrentQuery, setSelectedResponses, setCounts, updateCountsOptimistically } = + useDispatch( dashboardStore ); const urlStatus = searchParams.get( 'status' ); const statusFilter = getStatusFilter( urlStatus ); - const { selectedResponsesCount, currentStatus, currentQuery, filterOptions } = useSelect( + const { + selectedResponsesCount, + currentStatus, + currentQuery, + filterOptions, + totalItemsInbox, + totalItemsSpam, + totalItemsTrash, + } = useSelect( select => ( { selectedResponsesCount: select( dashboardStore ).getSelectedResponsesCount(), currentStatus: select( dashboardStore ).getCurrentStatus(), currentQuery: select( dashboardStore ).getCurrentQuery(), filterOptions: select( dashboardStore ).getFilters(), + totalItemsInbox: select( dashboardStore ).getInboxCount(), + totalItemsSpam: select( dashboardStore ).getSpamCount(), + totalItemsTrash: select( dashboardStore ).getTrashCount(), } ), [] ); @@ -115,7 +128,6 @@ export default function useInboxData(): UseInboxDataReturn { [ rawRecords ] ); - const [ counts, setCounts ] = useState( { inbox: 0, spam: 0, trash: 0 } ); const [ isLoadingCounts, setIsLoadingCounts ] = useState( false ); useEffect( () => { @@ -143,12 +155,18 @@ export default function useInboxData(): UseInboxDataReturn { }; fetchCounts(); - }, [ currentQuery?.search, currentQuery?.parent, currentQuery?.before, currentQuery?.after ] ); + }, [ + currentQuery?.search, + currentQuery?.parent, + currentQuery?.before, + currentQuery?.after, + setCounts, + ] ); return { - totalItemsInbox: counts.inbox, - totalItemsSpam: counts.spam, - totalItemsTrash: counts.trash, + totalItemsInbox, + totalItemsSpam, + totalItemsTrash, records, isLoadingData: isLoadingRecordsData, isLoadingCounts, @@ -161,5 +179,6 @@ export default function useInboxData(): UseInboxDataReturn { currentQuery, setCurrentQuery, filterOptions, + updateCountsOptimistically, }; } diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index b68359c4109b9..f2f1d373c1774 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -120,6 +120,7 @@ export default function InboxView() { isLoadingData, totalItems, totalPages, + updateCountsOptimistically, } = useInboxData(); const queryArgs = useMemo( () => { @@ -333,14 +334,36 @@ export default function InboxView() { ); const actions = useMemo( () => { + // Wrap actions with optimistic updates + const wrapActionWithOptimisticUpdate = ( action, toStatus ) => ( { + ...action, + async callback( items, context ) { + // statusFilter represents the current view: 'draft,publish' (inbox), 'spam', or 'trash' + // For inbox, we need to map to individual status from items + const fromStatus = statusFilter === 'draft,publish' ? items[ 0 ]?.status : statusFilter; + // Optimistically update counts + updateCountsOptimistically( fromStatus, toStatus, items.length ); + // Call original action + return action.callback( items, context ); + }, + } ); + const _actions = [ markAsReadAction, markAsUnreadAction, - markAsSpamAction, - markAsNotSpamAction, - moveToTrashAction, - restoreAction, - deleteAction, + wrapActionWithOptimisticUpdate( markAsSpamAction, 'spam' ), + wrapActionWithOptimisticUpdate( markAsNotSpamAction, 'publish' ), + wrapActionWithOptimisticUpdate( moveToTrashAction, 'trash' ), + wrapActionWithOptimisticUpdate( restoreAction, 'publish' ), + { + ...deleteAction, + async callback( items, context ) { + const fromStatus = statusFilter === 'draft,publish' ? items[ 0 ]?.status : statusFilter; + // Optimistically update counts (permanent delete, no toStatus) + updateCountsOptimistically( fromStatus, 'deleted', items.length ); + return deleteAction.callback( items, context ); + }, + }, ]; if ( isMobile ) { _actions.unshift( viewActionModal ); @@ -356,7 +379,7 @@ export default function InboxView() { } ); } return _actions; - }, [ isMobile, onChangeSelection, selection ] ); + }, [ isMobile, onChangeSelection, selection, updateCountsOptimistically, statusFilter ] ); const resetPage = useCallback( () => { view.page = 1; diff --git a/projects/packages/forms/src/dashboard/store/action-types.js b/projects/packages/forms/src/dashboard/store/action-types.js index b3cdb929102b3..05f298c8b4aa3 100644 --- a/projects/packages/forms/src/dashboard/store/action-types.js +++ b/projects/packages/forms/src/dashboard/store/action-types.js @@ -2,3 +2,4 @@ export const RECEIVE_FILTERS = 'RECEIVE_FILTERS'; export const INVALIDATE_FILTERS = 'INVALIDATE_FILTERS'; export const SET_CURRENT_QUERY = 'SET_CURRENT_QUERY'; export const SET_SELECTED_RESPONSES = 'SET_SELECTED_RESPONSES'; +export const SET_COUNTS = 'SET_COUNTS'; diff --git a/projects/packages/forms/src/dashboard/store/actions.js b/projects/packages/forms/src/dashboard/store/actions.js index 55f5c005b7600..5ebc315eb7333 100644 --- a/projects/packages/forms/src/dashboard/store/actions.js +++ b/projects/packages/forms/src/dashboard/store/actions.js @@ -7,6 +7,7 @@ import { RECEIVE_FILTERS, SET_CURRENT_QUERY, INVALIDATE_FILTERS, + SET_COUNTS, } from './action-types'; /** @@ -52,6 +53,19 @@ export function setCurrentQuery( currentQuery ) { }; } +/** + * Set the status counts. + * + * @param {object} counts - The counts object with inbox, spam, and trash. + * @return {object} Action object. + */ +export function setCounts( counts ) { + return { + type: SET_COUNTS, + counts, + }; +} + /** * Performs a bulk action on responses. * diff --git a/projects/packages/forms/src/dashboard/store/reducer.js b/projects/packages/forms/src/dashboard/store/reducer.js index 078860290cf4b..226c084f77b3a 100644 --- a/projects/packages/forms/src/dashboard/store/reducer.js +++ b/projects/packages/forms/src/dashboard/store/reducer.js @@ -6,7 +6,12 @@ import { isEqual } from 'lodash'; /** * Internal dependencies */ -import { SET_SELECTED_RESPONSES, RECEIVE_FILTERS, SET_CURRENT_QUERY } from './action-types'; +import { + SET_SELECTED_RESPONSES, + RECEIVE_FILTERS, + SET_CURRENT_QUERY, + SET_COUNTS, +} from './action-types'; const filters = ( state = {}, action ) => { if ( action.type === RECEIVE_FILTERS ) { @@ -29,8 +34,16 @@ const selectedResponsesFromCurrentDataset = ( state = [], action ) => { return state; }; +const counts = ( state = { inbox: 0, spam: 0, trash: 0 }, action ) => { + if ( action.type === SET_COUNTS ) { + return action.counts; + } + return state; +}; + export default combineReducers( { selectedResponsesFromCurrentDataset, filters, currentQuery, + counts, } ); diff --git a/projects/packages/forms/src/dashboard/store/selectors.js b/projects/packages/forms/src/dashboard/store/selectors.js index 560438bf6c2d0..ffb740419d1b2 100644 --- a/projects/packages/forms/src/dashboard/store/selectors.js +++ b/projects/packages/forms/src/dashboard/store/selectors.js @@ -4,3 +4,7 @@ export const getCurrentStatus = state => state.currentQuery?.status ?? 'draft,pu export const getSelectedResponsesFromCurrentDataset = state => state.selectedResponsesFromCurrentDataset; export const getSelectedResponsesCount = state => state.selectedResponsesFromCurrentDataset.length; +export const getCounts = state => state.counts; +export const getInboxCount = state => state.counts.inbox; +export const getSpamCount = state => state.counts.spam; +export const getTrashCount = state => state.counts.trash;