Skip to content

Commit ed80225

Browse files
lezamaenejb
andauthored
Forms: Add optimized counts endpoint (#45427)
* Forms: Add counts endpoint * Forms: Simplify counts endpoint implementation * Add props for spam/trash counts to empty buttons * Allow for source to be selected * Add tests * Change 'parent' param to accept single integer --------- Co-authored-by: Enej Bajgoric <[email protected]>
1 parent 3816aac commit ed80225

File tree

13 files changed

+316
-67
lines changed

13 files changed

+316
-67
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+
Replace 3 separate count REST API requests with 1 optimized database query using CASE statements to improve inbox performance.

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,45 @@ public function register_routes() {
277277
),
278278
)
279279
);
280+
281+
// Get optimized status counts.
282+
register_rest_route(
283+
$this->namespace,
284+
$this->rest_base . '/counts',
285+
array(
286+
'methods' => \WP_REST_Server::READABLE,
287+
'permission_callback' => array( $this, 'get_items_permissions_check' ),
288+
'callback' => array( $this, 'get_status_counts' ),
289+
'args' => array(
290+
'search' => array(
291+
'description' => 'Limit results to those matching a string.',
292+
'type' => 'string',
293+
'sanitize_callback' => 'sanitize_text_field',
294+
'validate_callback' => 'rest_validate_request_arg',
295+
),
296+
'parent' => array(
297+
'description' => 'Limit results to those of a specific parent ID.',
298+
'type' => 'integer',
299+
'sanitize_callback' => 'absint',
300+
'validate_callback' => 'rest_validate_request_arg',
301+
),
302+
'before' => array(
303+
'description' => 'Limit results to feedback published before a given ISO8601 compliant date.',
304+
'type' => 'string',
305+
'format' => 'date-time',
306+
'sanitize_callback' => 'sanitize_text_field',
307+
'validate_callback' => 'rest_validate_request_arg',
308+
),
309+
'after' => array(
310+
'description' => 'Limit results to feedback published after a given ISO8601 compliant date.',
311+
'type' => 'string',
312+
'format' => 'date-time',
313+
'sanitize_callback' => 'sanitize_text_field',
314+
'validate_callback' => 'rest_validate_request_arg',
315+
),
316+
),
317+
)
318+
);
280319
}
281320

282321
/**
@@ -325,6 +364,63 @@ static function ( $post_id ) {
325364
);
326365
}
327366

367+
/**
368+
* Retrieves status counts for inbox, spam, and trash.
369+
*
370+
* @param WP_REST_Request $request Full data about the request.
371+
* @return WP_REST_Response Response object on success.
372+
*/
373+
public function get_status_counts( $request ) {
374+
global $wpdb;
375+
376+
$search = $request->get_param( 'search' );
377+
$parent = $request->get_param( 'parent' );
378+
$before = $request->get_param( 'before' );
379+
$after = $request->get_param( 'after' );
380+
381+
$where_conditions = array( $wpdb->prepare( 'post_type = %s', 'feedback' ) );
382+
383+
if ( ! empty( $search ) ) {
384+
$search_like = '%' . $wpdb->esc_like( $search ) . '%';
385+
$where_conditions[] = $wpdb->prepare( '(post_title LIKE %s OR post_content LIKE %s)', $search_like, $search_like );
386+
}
387+
388+
if ( ! empty( $parent ) ) {
389+
$where_conditions[] = $wpdb->prepare( 'post_parent = %d', $parent );
390+
}
391+
392+
if ( ! empty( $before ) ) {
393+
$where_conditions[] = $wpdb->prepare( 'post_date <= %s', $before );
394+
}
395+
396+
if ( ! empty( $after ) ) {
397+
$where_conditions[] = $wpdb->prepare( 'post_date >= %s', $after );
398+
}
399+
400+
$where_clause = implode( ' AND ', $where_conditions );
401+
402+
// Execute single query with CASE statements for all status counts.
403+
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
404+
$counts = $wpdb->get_row(
405+
"SELECT
406+
SUM(CASE WHEN post_status IN ('publish', 'draft') THEN 1 ELSE 0 END) as inbox,
407+
SUM(CASE WHEN post_status = 'spam' THEN 1 ELSE 0 END) as spam,
408+
SUM(CASE WHEN post_status = 'trash' THEN 1 ELSE 0 END) as trash
409+
FROM $wpdb->posts
410+
WHERE $where_clause",
411+
ARRAY_A
412+
);
413+
// phpcs:enable
414+
415+
$result = array(
416+
'inbox' => (int) ( $counts['inbox'] ?? 0 ),
417+
'spam' => (int) ( $counts['spam'] ?? 0 ),
418+
'trash' => (int) ( $counts['trash'] ?? 0 ),
419+
);
420+
421+
return rest_ensure_response( $result );
422+
}
423+
328424
/**
329425
* Adds the additional fields to the item's schema.
330426
*

projects/packages/forms/src/dashboard/components/empty-spam-button/index.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,38 @@ type CoreStore = typeof coreStore & {
2020
invalidateResolution: ( selector: string, args: unknown[] ) => void;
2121
};
2222

23+
interface EmptySpamButtonProps {
24+
totalItemsSpam?: number;
25+
isLoadingCounts?: boolean;
26+
}
27+
2328
/**
2429
* Renders a button to empty form responses.
2530
*
31+
* @param {object} props - Component props.
32+
* @param {number} props.totalItemsSpam - The total number of spam items (optional, will use hook if not provided).
33+
* @param {boolean} props.isLoadingCounts - Whether counts are loading (optional, will use hook if not provided).
2634
* @return {JSX.Element} The empty spam button.
2735
*/
28-
const EmptySpamButton = (): JSX.Element => {
36+
const EmptySpamButton = ( {
37+
totalItemsSpam: totalItemsSpamProp,
38+
isLoadingCounts: isLoadingCountsProp,
39+
}: EmptySpamButtonProps = {} ): JSX.Element => {
2940
const [ isConfirmDialogOpen, setConfirmDialogOpen ] = useState( false );
3041
const [ isEmptying, setIsEmptying ] = useState( false );
3142
const [ isEmpty, setIsEmpty ] = useState( true );
3243
const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
3344
const { invalidateResolution } = useDispatch( coreStore ) as unknown as CoreStore;
3445

35-
const { selectedResponsesCount, currentQuery, totalItemsSpam, isLoadingData } = useInboxData();
46+
// Use props if provided, otherwise use hook
47+
const hookData = useInboxData();
48+
const totalItemsSpam = totalItemsSpamProp ?? hookData.totalItemsSpam;
49+
const isLoadingCounts = isLoadingCountsProp ?? hookData.isLoadingCounts;
50+
const { selectedResponsesCount, currentQuery } = hookData;
3651

3752
useEffect( () => {
38-
setIsEmpty( isLoadingData || ! totalItemsSpam );
39-
}, [ totalItemsSpam, isLoadingData ] );
53+
setIsEmpty( isLoadingCounts || ! totalItemsSpam );
54+
}, [ totalItemsSpam, isLoadingCounts ] );
4055

4156
const openConfirmDialog = useCallback( () => setConfirmDialogOpen( true ), [] );
4257
const closeConfirmDialog = useCallback( () => setConfirmDialogOpen( false ), [] );

projects/packages/forms/src/dashboard/components/empty-trash-button/index.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,38 @@ type CoreStore = typeof coreStore & {
2020
invalidateResolution: ( selector: string, args: unknown[] ) => void;
2121
};
2222

23+
interface EmptyTrashButtonProps {
24+
totalItemsTrash?: number;
25+
isLoadingCounts?: boolean;
26+
}
27+
2328
/**
2429
* Renders a button to empty form responses.
2530
*
31+
* @param {object} props - Component props.
32+
* @param {number} props.totalItemsTrash - The total number of trash items (optional, will use hook if not provided).
33+
* @param {boolean} props.isLoadingCounts - Whether counts are loading (optional, will use hook if not provided).
2634
* @return {JSX.Element} The empty trash button.
2735
*/
28-
const EmptyTrashButton = (): JSX.Element => {
36+
const EmptyTrashButton = ( {
37+
totalItemsTrash: totalItemsTrashProp,
38+
isLoadingCounts: isLoadingCountsProp,
39+
}: EmptyTrashButtonProps = {} ): JSX.Element => {
2940
const [ isConfirmDialogOpen, setConfirmDialogOpen ] = useState( false );
3041
const [ isEmptying, setIsEmptying ] = useState( false );
3142
const [ isEmpty, setIsEmpty ] = useState( true );
3243
const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
3344
const { invalidateResolution } = useDispatch( coreStore ) as unknown as CoreStore;
3445

35-
const { selectedResponsesCount, currentQuery, totalItemsTrash, isLoadingData } = useInboxData();
46+
// Use props if provided, otherwise use hook
47+
const hookData = useInboxData();
48+
const totalItemsTrash = totalItemsTrashProp ?? hookData.totalItemsTrash;
49+
const isLoadingCounts = isLoadingCountsProp ?? hookData.isLoadingCounts;
50+
const { selectedResponsesCount, currentQuery } = hookData;
3651

3752
useEffect( () => {
38-
setIsEmpty( isLoadingData || ! totalItemsTrash );
39-
}, [ totalItemsTrash, isLoadingData ] );
53+
setIsEmpty( isLoadingCounts || ! totalItemsTrash );
54+
}, [ totalItemsTrash, isLoadingCounts ] );
4055

4156
const openConfirmDialog = useCallback( () => setConfirmDialogOpen( true ), [] );
4257
const closeConfirmDialog = useCallback( () => setConfirmDialogOpen( false ), [] );

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

Lines changed: 58 additions & 36 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, store as coreDataStore } from '@wordpress/core-data';
56
import { useDispatch, useSelect } from '@wordpress/data';
7+
import { useEffect, useMemo, 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;
@@ -93,52 +97,70 @@ export default function useInboxData(): UseInboxDataReturn {
9397
[ rawRecords ]
9498
);
9599

96-
const { isResolving: isLoadingInboxData, totalItems: totalItemsInbox = 0 } = useEntityRecords(
97-
'postType',
98-
'feedback',
99-
{
100-
page: 1,
101-
search: '',
102-
...currentQuery,
103-
status: 'publish,draft',
104-
per_page: 1,
105-
_fields: 'id',
106-
}
107-
);
100+
// Normalize the current query values to ensure consistent comparisons.
101+
const searchValue = currentQuery?.search || undefined;
102+
const parentValue = currentQuery?.parent || undefined;
103+
const beforeValue = currentQuery?.before || undefined;
104+
const afterValue = currentQuery?.after || undefined;
108105

109-
const { isResolving: isLoadingSpamData, totalItems: totalItemsSpam = 0 } = useEntityRecords(
110-
'postType',
111-
'feedback',
112-
{
113-
page: 1,
114-
search: '',
115-
...currentQuery,
116-
status: 'spam',
117-
per_page: 1,
118-
_fields: 'id',
106+
const countsQueryParams = useMemo( () => {
107+
const params: Record< string, unknown > = {};
108+
if ( searchValue ) {
109+
params.search = searchValue;
119110
}
120-
);
121-
122-
const { isResolving: isLoadingTrashData, totalItems: totalItemsTrash = 0 } = useEntityRecords(
123-
'postType',
124-
'feedback',
125-
{
126-
page: 1,
127-
search: '',
128-
...currentQuery,
129-
status: 'trash',
130-
per_page: 1,
131-
_fields: 'id',
111+
if ( parentValue ) {
112+
params.parent = parentValue;
113+
}
114+
if ( beforeValue ) {
115+
params.before = beforeValue;
116+
}
117+
if ( afterValue ) {
118+
params.after = afterValue;
132119
}
120+
return params;
121+
}, [ searchValue, parentValue, beforeValue, afterValue ] );
122+
123+
// Fetch counts using the optimized endpoint
124+
const [ counts, setCounts ] = useState< { inbox: number; spam: number; trash: number } | null >(
125+
null
133126
);
127+
const [ isLoadingCounts, setIsLoadingCounts ] = useState( true );
128+
129+
useEffect( () => {
130+
let isCancelled = false;
131+
setIsLoadingCounts( true );
132+
133+
const path = addQueryArgs( '/wp/v2/feedback/counts', countsQueryParams );
134+
135+
apiFetch< { inbox: number; spam: number; trash: number } >( { path } )
136+
.then( results => {
137+
if ( ! isCancelled ) {
138+
setCounts( results );
139+
setIsLoadingCounts( false );
140+
}
141+
} )
142+
.catch( () => {
143+
if ( ! isCancelled ) {
144+
setIsLoadingCounts( false );
145+
}
146+
} );
147+
148+
return () => {
149+
isCancelled = true;
150+
};
151+
}, [ countsQueryParams ] );
152+
153+
const totalItemsInbox = counts?.inbox ?? 0;
154+
const totalItemsSpam = counts?.spam ?? 0;
155+
const totalItemsTrash = counts?.trash ?? 0;
134156

135157
return {
136158
totalItemsInbox,
137159
totalItemsSpam,
138160
totalItemsTrash,
139161
records,
140-
isLoadingData:
141-
isLoadingRecordsData || isLoadingInboxData || isLoadingSpamData || isLoadingTrashData,
162+
isLoadingData: isLoadingRecordsData,
163+
isLoadingCounts,
142164
totalItems,
143165
totalPages,
144166
selectedResponsesCount,

0 commit comments

Comments
 (0)