Skip to content

Commit d6ea2bf

Browse files
committed
Forms: Replace 3 count queries with single optimized endpoint
Reduces from 3 separate REST requests to 1 optimized database query with caching. Related: #45339
1 parent 2784207 commit d6ea2bf

File tree

5 files changed

+155
-49
lines changed

5 files changed

+155
-49
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: changed
3+
4+
Forms: replace 3 separate count queries with single optimized counts endpoint.

projects/packages/forms/src/contact-form/class-contact-form-endpoint.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,39 @@ public function register_routes() {
254254
'callback' => array( $this, 'get_forms_config' ),
255255
)
256256
);
257+
258+
register_rest_route(
259+
$this->namespace,
260+
$this->rest_base . '/counts',
261+
array(
262+
'methods' => \WP_REST_Server::READABLE,
263+
'permission_callback' => array( $this, 'get_items_permissions_check' ),
264+
'callback' => array( $this, 'get_status_counts' ),
265+
'args' => array(
266+
'search' => array(
267+
'type' => 'string',
268+
'sanitize_callback' => 'sanitize_text_field',
269+
),
270+
'parent' => array(
271+
'type' => 'array',
272+
'items' => array(
273+
'type' => 'integer',
274+
),
275+
'sanitize_callback' => function ( $value ) {
276+
return array_map( 'absint', (array) $value );
277+
},
278+
),
279+
'before' => array(
280+
'type' => 'string',
281+
'sanitize_callback' => 'sanitize_text_field',
282+
),
283+
'after' => array(
284+
'type' => 'string',
285+
'sanitize_callback' => 'sanitize_text_field',
286+
),
287+
),
288+
)
289+
);
257290
}
258291

259292
/**
@@ -302,6 +335,75 @@ static function ( $post_id ) {
302335
);
303336
}
304337

338+
/**
339+
* Retrieves status counts for inbox, spam, and trash in a single optimized query.
340+
*
341+
* @param WP_REST_Request $request Full data about the request.
342+
* @return WP_REST_Response Response object on success.
343+
*/
344+
public function get_status_counts( $request ) {
345+
global $wpdb;
346+
347+
$search = $request->get_param( 'search' );
348+
$parent = $request->get_param( 'parent' );
349+
$before = $request->get_param( 'before' );
350+
$after = $request->get_param( 'after' );
351+
352+
$cache_key = 'jetpack_forms_status_counts_' . md5( wp_json_encode( compact( 'search', 'parent', 'before', 'after' ) ) );
353+
$cached_result = get_transient( $cache_key );
354+
if ( false !== $cached_result ) {
355+
return rest_ensure_response( $cached_result );
356+
}
357+
358+
$where_conditions = array( $wpdb->prepare( 'post_type = %s', 'feedback' ) );
359+
$join_clauses = '';
360+
361+
if ( ! empty( $search ) ) {
362+
$search_like = '%' . $wpdb->esc_like( $search ) . '%';
363+
$where_conditions[] = $wpdb->prepare( '(post_title LIKE %s OR post_content LIKE %s)', $search_like, $search_like );
364+
}
365+
366+
if ( ! empty( $parent ) && is_array( $parent ) ) {
367+
$parent_ids = array_map( 'absint', $parent );
368+
$parent_ids_string = implode( ',', $parent_ids );
369+
$where_conditions[] = "post_parent IN ($parent_ids_string)";
370+
}
371+
372+
if ( ! empty( $before ) || ! empty( $after ) ) {
373+
if ( ! empty( $before ) ) {
374+
$where_conditions[] = $wpdb->prepare( 'post_date <= %s', $before );
375+
}
376+
if ( ! empty( $after ) ) {
377+
$where_conditions[] = $wpdb->prepare( 'post_date >= %s', $after );
378+
}
379+
}
380+
381+
$where_clause = implode( ' AND ', $where_conditions );
382+
383+
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
384+
$counts = $wpdb->get_row(
385+
"SELECT
386+
SUM(CASE WHEN post_status IN ('publish', 'draft') THEN 1 ELSE 0 END) as inbox,
387+
SUM(CASE WHEN post_status = 'spam' THEN 1 ELSE 0 END) as spam,
388+
SUM(CASE WHEN post_status = 'trash' THEN 1 ELSE 0 END) as trash
389+
FROM $wpdb->posts
390+
$join_clauses
391+
WHERE $where_clause",
392+
ARRAY_A
393+
);
394+
// phpcs:enable
395+
396+
$result = array(
397+
'inbox' => (int) ( $counts['inbox'] ?? 0 ),
398+
'spam' => (int) ( $counts['spam'] ?? 0 ),
399+
'trash' => (int) ( $counts['trash'] ?? 0 ),
400+
);
401+
402+
set_transient( $cache_key, $result, 30 );
403+
404+
return rest_ensure_response( $result );
405+
}
406+
305407
/**
306408
* Adds the additional fields to the item's schema.
307409
*

projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/**
22
* External dependencies
33
*/
4+
import apiFetch from '@wordpress/api-fetch';
45
import { useEntityRecords } from '@wordpress/core-data';
56
import { useDispatch, useSelect } from '@wordpress/data';
7+
import { useEffect, useState } from '@wordpress/element';
8+
import { addQueryArgs } from '@wordpress/url';
69
import { useSearchParams } from 'react-router';
710
/**
811
* Internal dependencies
@@ -36,6 +39,7 @@ interface UseInboxDataReturn {
3639
totalItemsTrash: number;
3740
records: FormResponse[];
3841
isLoadingData: boolean;
42+
isLoadingCounts: boolean;
3943
totalItems: number;
4044
totalPages: number;
4145
selectedResponsesCount: number;
@@ -96,52 +100,43 @@ export default function useInboxData(): UseInboxDataReturn {
96100

97101
const records = ( rawRecords || [] ) as FormResponse[];
98102

99-
const { isResolving: isLoadingInboxData, totalItems: totalItemsInbox = 0 } = useEntityRecords(
100-
'postType',
101-
'feedback',
102-
{
103-
page: 1,
104-
search: '',
105-
...currentQuery,
106-
status: 'publish,draft',
107-
per_page: 1,
108-
_fields: 'id',
109-
}
110-
);
103+
const [ counts, setCounts ] = useState( { inbox: 0, spam: 0, trash: 0 } );
104+
const [ isLoadingCounts, setIsLoadingCounts ] = useState( false );
111105

112-
const { isResolving: isLoadingSpamData, totalItems: totalItemsSpam = 0 } = useEntityRecords(
113-
'postType',
114-
'feedback',
115-
{
116-
page: 1,
117-
search: '',
118-
...currentQuery,
119-
status: 'spam',
120-
per_page: 1,
121-
_fields: 'id',
122-
}
123-
);
106+
useEffect( () => {
107+
const fetchCounts = async () => {
108+
setIsLoadingCounts( true );
109+
const params: Record< string, unknown > = {};
110+
if ( currentQuery?.search ) {
111+
params.search = currentQuery.search;
112+
}
113+
if ( currentQuery?.parent ) {
114+
params.parent = currentQuery.parent;
115+
}
116+
if ( currentQuery?.before ) {
117+
params.before = currentQuery.before;
118+
}
119+
if ( currentQuery?.after ) {
120+
params.after = currentQuery.after;
121+
}
122+
const path = addQueryArgs( '/wp/v2/feedback/counts', params );
123+
const response = await apiFetch< { inbox: number; spam: number; trash: number } >( {
124+
path,
125+
} );
126+
setCounts( response );
127+
setIsLoadingCounts( false );
128+
};
124129

125-
const { isResolving: isLoadingTrashData, totalItems: totalItemsTrash = 0 } = useEntityRecords(
126-
'postType',
127-
'feedback',
128-
{
129-
page: 1,
130-
search: '',
131-
...currentQuery,
132-
status: 'trash',
133-
per_page: 1,
134-
_fields: 'id',
135-
}
136-
);
130+
fetchCounts();
131+
}, [ currentQuery?.search, currentQuery?.parent, currentQuery?.before, currentQuery?.after ] );
137132

138133
return {
139-
totalItemsInbox,
140-
totalItemsSpam,
141-
totalItemsTrash,
134+
totalItemsInbox: counts.inbox,
135+
totalItemsSpam: counts.spam,
136+
totalItemsTrash: counts.trash,
142137
records,
143-
isLoadingData:
144-
isLoadingRecordsData || isLoadingInboxData || isLoadingSpamData || isLoadingTrashData,
138+
isLoadingData: isLoadingRecordsData,
139+
isLoadingCounts,
145140
totalItems,
146141
totalPages,
147142
selectedResponsesCount,

projects/packages/forms/src/dashboard/inbox/dataviews/index.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export default function InboxView() {
120120
totalPages,
121121
} = useInboxData();
122122

123-
useEffect( () => {
123+
const queryArgs = useMemo( () => {
124124
const _filters = view.filters?.reduce( ( accumulator, { field, value } ) => {
125125
if ( ! value ) {
126126
return accumulator;
@@ -135,17 +135,21 @@ export default function InboxView() {
135135
}
136136
return accumulator;
137137
}, {} );
138-
const _queryArgs = {
138+
const args = {
139139
per_page: view.perPage,
140140
page: view.page,
141-
search: view.search,
142141
..._filters,
143142
status: statusFilter,
144143
};
145-
// We need to keep the current query args in the store to be used in `export`
146-
// and for getting the total records per `status`.
147-
setCurrentQuery( _queryArgs );
148-
}, [ view, statusFilter, setCurrentQuery ] );
144+
if ( view.search ) {
145+
args.search = view.search;
146+
}
147+
return args;
148+
}, [ view.perPage, view.page, view.search, view.filters, statusFilter ] );
149+
150+
useEffect( () => {
151+
setCurrentQuery( queryArgs );
152+
}, [ queryArgs, setCurrentQuery ] );
149153
const data = useMemo(
150154
() =>
151155
records?.map( record => ( {

projects/packages/forms/src/dashboard/store/reducer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* External dependencies
33
*/
44
import { combineReducers } from '@wordpress/data';
5+
import { isEqual } from 'lodash';
56
/**
67
* Internal dependencies
78
*/
@@ -16,7 +17,7 @@ const filters = ( state = {}, action ) => {
1617

1718
const currentQuery = ( state = {}, action ) => {
1819
if ( action.type === SET_CURRENT_QUERY ) {
19-
return action.currentQuery;
20+
return isEqual( state, action.currentQuery ) ? state : action.currentQuery;
2021
}
2122
return state;
2223
};

0 commit comments

Comments
 (0)