Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions projects/packages/forms/changelog/optimize-inbox-counts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Forms: replace 3 separate count queries with single optimized counts endpoint.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
),
)
);
}

/**
Expand Down Expand Up @@ -325,6 +358,95 @@ 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.
*/
/**
* 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;

$search = $request->get_param( 'search' );
$parent = $request->get_param( 'parent' );
$before = $request->get_param( 'before' );
$after = $request->get_param( 'after' );

$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' ) );
$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 ),
);

if ( $is_default_view ) {
set_transient( 'jetpack_forms_status_counts_default', $result, 30 );
}

return rest_ensure_response( $result );
}

/**
* Adds the additional fields to the item's schema.
*
Expand Down Expand Up @@ -526,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.
Expand All @@ -551,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;
}
Expand Down Expand Up @@ -772,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 );
}

Expand All @@ -790,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 );
}

Expand All @@ -808,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 );
}

Expand Down
95 changes: 55 additions & 40 deletions projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -36,6 +40,7 @@
totalItemsTrash: number;
records: FormResponse[];
isLoadingData: boolean;
isLoadingCounts: boolean;
totalItems: number;
totalPages: number;
selectedResponsesCount: number;
Expand All @@ -45,6 +50,7 @@
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 = [
Expand All @@ -70,16 +76,28 @@
*/
export default function useInboxData(): UseInboxDataReturn {
const [ searchParams ] = useSearchParams();
const { setCurrentQuery, setSelectedResponses } = useDispatch( dashboardStore );
const { setCurrentQuery, setSelectedResponses, setCounts, updateCountsOptimistically } =

Check failure on line 79 in projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts

View workflow job for this annotation

GitHub Actions / Type checking

Property 'updateCountsOptimistically' does not exist on type 'PromisifiedActionCreators<typeof import("/home/runner/work/jetpack/jetpack/projects/packages/forms/src/dashboard/store/actions")>'.
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(),
} ),
[]
);
Expand Down Expand Up @@ -110,52 +128,48 @@
[ rawRecords ]
);

const { isResolving: isLoadingInboxData, totalItems: totalItemsInbox = 0 } = useEntityRecords(
'postType',
'feedback',
{
page: 1,
search: '',
...currentQuery,
status: 'publish,draft',
per_page: 1,
_fields: 'id',
}
);
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,
setCounts,
] );

return {
totalItemsInbox,
totalItemsSpam,
totalItemsTrash,
records,
isLoadingData:
isLoadingRecordsData || isLoadingInboxData || isLoadingSpamData || isLoadingTrashData,
isLoadingData: isLoadingRecordsData,
isLoadingCounts,
totalItems,
totalPages,
selectedResponsesCount,
Expand All @@ -165,5 +179,6 @@
currentQuery,
setCurrentQuery,
filterOptions,
updateCountsOptimistically,
};
}
Loading
Loading