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..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 @@ -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,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. * @@ -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. @@ -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; } @@ -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 ); } @@ -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 ); } @@ -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 ); } 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..d258b4f29cd2e 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; @@ -45,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 = [ @@ -70,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(), } ), [] ); @@ -110,52 +128,48 @@ 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 [ 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, @@ -165,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 7fd48a1f57043..f2f1d373c1774 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -120,9 +120,10 @@ export default function InboxView() { isLoadingData, totalItems, totalPages, + updateCountsOptimistically, } = useInboxData(); - useEffect( () => { + const queryArgs = useMemo( () => { const _filters = view.filters?.reduce( ( accumulator, { field, value } ) => { if ( ! value ) { return accumulator; @@ -137,17 +138,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 => ( { @@ -329,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 ); @@ -352,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 e04f4b370991a..226c084f77b3a 100644 --- a/projects/packages/forms/src/dashboard/store/reducer.js +++ b/projects/packages/forms/src/dashboard/store/reducer.js @@ -2,10 +2,16 @@ * External dependencies */ import { combineReducers } from '@wordpress/data'; +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 ) { @@ -16,7 +22,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; }; @@ -28,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;