diff --git a/projects/packages/forms/changelog/optimize-inbox-performance-large-spam b/projects/packages/forms/changelog/optimize-inbox-performance-large-spam
new file mode 100644
index 0000000000000..c29929c958f28
--- /dev/null
+++ b/projects/packages/forms/changelog/optimize-inbox-performance-large-spam
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Forms: optimize inbox performance
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 949d4305961ab..64f4dd193f346 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
@@ -254,6 +254,16 @@ public function register_routes() {
'callback' => array( $this, 'get_forms_config' ),
)
);
+
+ 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' ),
+ )
+ );
}
/**
@@ -263,43 +273,53 @@ public function register_routes() {
* @return WP_REST_Response Response object on success.
*/
public function get_filters() {
- // TODO: investigate how we can do this better regarding usage of $wpdb
- // performance by querying all the entities, etc..
global $wpdb;
+
+ $cache_key = 'jetpack_forms_filters';
+ $cached_result = get_transient( $cache_key );
+ if ( false !== $cached_result ) {
+ return rest_ensure_response( $cached_result );
+ }
+
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$months = $wpdb->get_results(
"SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month
FROM $wpdb->posts
WHERE post_type = 'feedback'
+ AND post_status IN ('publish', 'draft')
ORDER BY post_date DESC"
);
// phpcs:enable
+
$source_ids = Contact_Form_Plugin::get_all_parent_post_ids(
- array_diff_key( array( 'post_status' => array( 'draft', 'publish', 'spam', 'trash' ) ), array( 'post_parent' => '' ) )
+ array( 'post_status' => array( 'draft', 'publish' ) )
);
- return rest_ensure_response(
- array(
- 'date' => array_map(
- static function ( $row ) {
- return array(
- 'month' => (int) $row->month,
- 'year' => (int) $row->year,
- );
- },
- $months
- ),
- 'source' => array_map(
- static function ( $post_id ) {
- return array(
- 'id' => $post_id,
- 'title' => get_the_title( $post_id ),
- 'url' => get_permalink( $post_id ),
- );
- },
- $source_ids
- ),
- )
+
+ $result = array(
+ 'date' => array_map(
+ static function ( $row ) {
+ return array(
+ 'month' => (int) $row->month,
+ 'year' => (int) $row->year,
+ );
+ },
+ $months
+ ),
+ 'source' => array_map(
+ static function ( $post_id ) {
+ return array(
+ 'id' => $post_id,
+ 'title' => get_the_title( $post_id ),
+ 'url' => get_permalink( $post_id ),
+ );
+ },
+ $source_ids
+ ),
);
+
+ set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS );
+
+ return rest_ensure_response( $result );
}
/**
@@ -1068,4 +1088,47 @@ public function get_forms_config( WP_REST_Request $request ) { // phpcs:ignore V
return rest_ensure_response( $config );
}
+
+ /**
+ * Get optimized status counts for feedback posts.
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function get_status_counts( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ global $wpdb;
+
+ $cache_key = 'jetpack_forms_status_counts';
+ $cached_result = get_transient( $cache_key );
+ if ( false !== $cached_result ) {
+ return rest_ensure_response( $cached_result );
+ }
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $counts = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT
+ SUM(CASE WHEN post_status IN ('publish', 'draft') THEN 1 ELSE 0 END) as inbox,
+ SUM(CASE WHEN post_status = %s THEN 1 ELSE 0 END) as spam,
+ SUM(CASE WHEN post_status = %s THEN 1 ELSE 0 END) as trash
+ FROM $wpdb->posts
+ WHERE post_type = %s",
+ 'spam',
+ 'trash',
+ 'feedback'
+ ),
+ 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 );
+ }
}
diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php
index 7fc8bb5229949..421ad2e85c082 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php
@@ -77,6 +77,13 @@ class Contact_Form_Plugin {
*/
public static $step_count = 0;
+ /**
+ * REST controller instance used for cache invalidation.
+ *
+ * @var string
+ */
+ private $post_type = 'feedback';
+
/*
* Field keys that might be present in the entry json but we don't want to show to the admin
* since they not something that the visitor entered into the form.
@@ -180,6 +187,9 @@ public static function strip_tags( $data_with_tags ) {
protected function __construct() {
$this->add_shortcode();
+ add_action( 'transition_post_status', array( $this, 'maybe_invalidate_caches' ), 10, 3 );
+ add_action( 'deleted_post', array( $this, 'maybe_invalidate_caches_on_delete' ), 10, 2 );
+
// While generating the output of a text widget with a contact-form shortcode, we need to know its widget ID.
add_action( 'dynamic_sidebar', array( $this, 'track_current_widget' ) );
add_action( 'dynamic_sidebar_before', array( $this, 'track_current_widget_before' ) );
@@ -224,7 +234,7 @@ protected function __construct() {
// custom post type we'll use to keep copies of the feedback items
register_post_type(
- 'feedback',
+ $this->post_type,
array(
'labels' => array(
'name' => __( 'Form Responses', 'jetpack-forms' ),
@@ -2967,17 +2977,45 @@ public function esc_csv( $field ) {
* @return array The array of post IDs
*/
public static function get_all_parent_post_ids( $query_args = array() ) {
+ global $wpdb;
+
$default_query_args = array(
- 'fields' => 'id=>parent',
- 'posts_per_page' => 100000, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
- 'post_type' => 'feedback',
- 'post_status' => 'publish',
- 'suppress_filters' => false,
+ 'post_status' => 'publish',
);
$args = array_merge( $default_query_args, $query_args );
- // Get the feedbacks' parents' post IDs
- $feedbacks = get_posts( $args );
- return array_values( array_unique( array_values( $feedbacks ) ) );
+
+ $statuses = is_array( $args['post_status'] ) ? $args['post_status'] : explode( ',', $args['post_status'] );
+ $statuses = array_map( 'trim', $statuses );
+ sort( $statuses );
+
+ $cache_key = 'jetpack_forms_parent_ids_' . md5( implode( ',', $statuses ) );
+
+ $cached_result = get_transient( $cache_key );
+ if ( false !== $cached_result ) {
+ return $cached_result;
+ }
+
+ $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) );
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $parent_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ "SELECT DISTINCT post_parent
+ FROM $wpdb->posts
+ WHERE post_type = %s
+ AND post_status IN ($placeholders)
+ AND post_parent > 0
+ ORDER BY post_parent ASC",
+ array_merge( array( 'feedback' ), $statuses )
+ )
+ );
+ // phpcs:enable
+ $parent_ids = array_map( 'intval', $parent_ids );
+
+ set_transient( $cache_key, $parent_ids, 5 * MINUTE_IN_SECONDS );
+
+ return $parent_ids;
}
/**
@@ -3443,4 +3481,50 @@ public function redirect_edit_feedback_to_jetpack_forms() {
wp_safe_redirect( $redirect_url );
exit;
}
+
+ /**
+ * Maybe invalidate caches when a post status changes.
+ *
+ * Hooked to transition_post_status action.
+ *
+ * @param string $new_status New post status.
+ * @param string $old_status Old post status.
+ * @param \WP_Post $post Post object.
+ */
+ public function maybe_invalidate_caches( $new_status, $old_status, $post ) {
+ if ( $this->post_type !== $post->post_type || $new_status === $old_status ) {
+ return;
+ }
+
+ $this->invalidate_feedback_caches();
+ }
+
+ /**
+ * Maybe invalidate caches when a post is deleted.
+ *
+ * Hooked to deleted_post action.
+ *
+ * @param int $post_id Post ID.
+ * @param \WP_Post $post Post object.
+ */
+ public function maybe_invalidate_caches_on_delete( $post_id, $post ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionPar
+ if ( $this->post_type !== $post->post_type ) {
+ return;
+ }
+
+ $this->invalidate_feedback_caches();
+ }
+
+ /**
+ * Invalidate feedback caches when content changes.
+ */
+ private function invalidate_feedback_caches() {
+ delete_transient( 'jetpack_forms_status_counts' );
+ delete_transient( 'jetpack_forms_filters' );
+ delete_transient( 'jetpack_forms_parent_ids_' . md5( 'publish' ) );
+ delete_transient( 'jetpack_forms_parent_ids_' . md5( 'draft,publish' ) );
+
+ wp_cache_delete( 'jetpack_forms_parent_ids_' . md5( 'publish' ), 'transient' );
+ wp_cache_delete( 'jetpack_forms_parent_ids_' . md5( 'draft,publish' ), 'transient' );
+ }
}
diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php
index 3f4eafd68d3c7..b34f193624d56 100644
--- a/projects/packages/forms/src/dashboard/class-dashboard.php
+++ b/projects/packages/forms/src/dashboard/class-dashboard.php
@@ -71,7 +71,7 @@ public function load_admin_scripts() {
'in_footer' => true,
'textdomain' => 'jetpack-forms',
'enqueue' => true,
- 'dependencies' => array( 'wp-api-fetch' ),
+ 'dependencies' => array( 'wp-api-fetch', 'wp-data', 'wp-core-data', 'wp-dom-ready' ),
)
);
@@ -83,14 +83,49 @@ public function load_admin_scripts() {
Connection_Initial_State::render_script( self::SCRIPT_HANDLE );
// Preload Forms endpoints needed in dashboard context.
- $preload_paths = array(
+ // Pre-fetch the first inbox page so the UI renders instantly on first load.
+ $preload_params = array(
+ '_fields' => 'id,status,date,date_gmt,author_name,author_email,author_url,author_avatar,ip,entry_title,entry_permalink,has_file,fields',
+ 'context' => 'view',
+ 'order' => 'desc',
+ 'orderby' => 'date',
+ 'page' => 1,
+ 'per_page' => 20,
+ 'status' => 'draft,publish',
+ );
+ \ksort( $preload_params );
+ $initial_responses_path = \add_query_arg( $preload_params, '/wp/v2/feedback' );
+ $initial_responses_locale_path = \add_query_arg(
+ \array_merge(
+ $preload_params,
+ array( '_locale' => 'user' )
+ ),
+ '/wp/v2/feedback'
+ );
+ $preload_paths = array(
+ '/wp/v2/types?context=view',
'/wp/v2/feedback/config',
'/wp/v2/feedback/integrations?version=2',
+ '/wp/v2/feedback/counts',
+ '/wp/v2/feedback/filters',
+ $initial_responses_path,
+ $initial_responses_locale_path,
);
- $preload_data = array_reduce( $preload_paths, 'rest_preload_api_request', array() );
+ $preload_data_raw = array_reduce( $preload_paths, 'rest_preload_api_request', array() );
+
+ // Normalize keys to match what apiFetch will request (without domain).
+ $preload_data = array();
+ foreach ( $preload_data_raw as $key => $value ) {
+ $normalized_key = preg_replace( '#^https?://[^/]+/wp-json#', '', $key );
+ $preload_data[ $normalized_key ] = $value;
+ }
+
wp_add_inline_script(
self::SCRIPT_HANDLE,
- 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( ' . wp_json_encode( $preload_data ) . ' ) );',
+ sprintf(
+ 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
+ wp_json_encode( $preload_data )
+ ),
'before'
);
}
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 2e9e19128f37f..33efa051b193d 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,10 @@
/**
* External dependencies
*/
+import apiFetch from '@wordpress/api-fetch';
import { useEntityRecords } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
+import { useEffect, useMemo, useState } from '@wordpress/element';
import { useSearchParams } from 'react-router';
/**
* Internal dependencies
@@ -36,6 +38,7 @@ interface UseInboxDataReturn {
totalItemsTrash: number;
records: FormResponse[];
isLoadingData: boolean;
+ isLoadingCounts: boolean;
totalItems: number;
totalPages: number;
selectedResponsesCount: number;
@@ -47,6 +50,22 @@ interface UseInboxDataReturn {
filterOptions: Record< string, unknown >;
}
+const RESPONSE_FIELDS = [
+ 'id',
+ 'status',
+ 'date',
+ 'date_gmt',
+ 'author_name',
+ 'author_email',
+ 'author_url',
+ 'author_avatar',
+ 'ip',
+ 'entry_title',
+ 'entry_permalink',
+ 'has_file',
+ 'fields',
+].join( ',' );
+
/**
* Hook to get all inbox related data.
*
@@ -68,63 +87,87 @@ export default function useInboxData(): UseInboxDataReturn {
[]
);
+ const queryArgs = useMemo( () => {
+ return {
+ ...currentQuery,
+ context: 'view',
+ _fields: RESPONSE_FIELDS,
+ };
+ }, [ currentQuery ] );
+
+ const countsQueryKey = useMemo( () => {
+ return JSON.stringify( {
+ status: statusFilter,
+ parent: currentQuery?.parent,
+ search: currentQuery?.search,
+ after: currentQuery?.after,
+ before: currentQuery?.before,
+ } );
+ }, [
+ statusFilter,
+ currentQuery?.parent,
+ currentQuery?.search,
+ currentQuery?.after,
+ currentQuery?.before,
+ ] );
+
const {
records: rawRecords,
- isResolving: isLoadingRecordsData,
+ hasResolved,
totalItems,
totalPages,
- } = useEntityRecords( 'postType', 'feedback', currentQuery );
+ } = useEntityRecords( 'postType', 'feedback', queryArgs );
- const records = ( rawRecords || [] ) as FormResponse[];
+ const records = useMemo( () => ( rawRecords || [] ) as FormResponse[], [ rawRecords ] );
- const { isResolving: isLoadingInboxData, totalItems: totalItemsInbox = 0 } = useEntityRecords(
- 'postType',
- 'feedback',
- {
- page: 1,
- search: '',
- ...currentQuery,
- status: 'publish,draft',
- per_page: 1,
- _fields: 'id',
- }
- );
+ const effectiveTotalItems = typeof totalItems === 'number' ? totalItems : records.length;
+ const effectiveTotalPages = typeof totalPages === 'number' ? totalPages : 0;
- const { isResolving: isLoadingSpamData, totalItems: totalItemsSpam = 0 } = useEntityRecords(
- 'postType',
- 'feedback',
- {
- page: 1,
- search: '',
- ...currentQuery,
- status: 'spam',
- per_page: 1,
- _fields: 'id',
- }
- );
+ const isLoadingRecordsData = ! rawRecords?.length && ! hasResolved;
- const { isResolving: isLoadingTrashData, totalItems: totalItemsTrash = 0 } = useEntityRecords(
- 'postType',
- 'feedback',
- {
- page: 1,
- search: '',
- ...currentQuery,
- status: 'trash',
- per_page: 1,
- _fields: 'id',
- }
- );
+ // Use optimized counts endpoint instead of 3 separate queries.
+ const [ counts, setCounts ] = useState( { inbox: 0, spam: 0, trash: 0 } );
+ const [ isLoadingCounts, setIsLoadingCounts ] = useState( false );
+
+ useEffect( () => {
+ let isMounted = true;
+
+ const fetchCounts = async () => {
+ setIsLoadingCounts( true );
+ try {
+ const response = await apiFetch< { inbox: number; spam: number; trash: number } >( {
+ path: '/wp/v2/feedback/counts',
+ } );
+ if ( isMounted ) {
+ setCounts( response );
+ }
+ } catch {
+ // Silently fail - counts are non-critical
+ } finally {
+ if ( isMounted ) {
+ setIsLoadingCounts( false );
+ }
+ }
+ };
+
+ fetchCounts();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [ countsQueryKey, totalItems ] );
+
+ const isLoadingData = isLoadingRecordsData;
return {
- totalItemsInbox,
- totalItemsSpam,
- totalItemsTrash,
+ totalItemsInbox: counts.inbox,
+ totalItemsSpam: counts.spam,
+ totalItemsTrash: counts.trash,
records,
- isLoadingData:
- isLoadingRecordsData || isLoadingInboxData || isLoadingSpamData || isLoadingTrashData,
- totalItems,
- totalPages,
+ isLoadingData,
+ isLoadingCounts,
+ totalItems: effectiveTotalItems,
+ totalPages: effectiveTotalPages,
selectedResponsesCount,
setSelectedResponses,
statusFilter,
diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
index 1cd9d5d5c3c58..0acd22e31406c 100644
--- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
+++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js
@@ -13,7 +13,7 @@ import { dateI18n, getSettings as getDateSettings } from '@wordpress/date';
import { useCallback, useMemo, useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { __ } from '@wordpress/i18n';
-import { isEmpty } from 'lodash';
+import { isEmpty, isEqual } from 'lodash';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router';
/**
@@ -118,6 +118,7 @@ export default function InboxView() {
isLoadingData,
totalItems,
totalPages,
+ currentQuery,
} = useInboxData();
useEffect( () => {
@@ -135,17 +136,23 @@ export default function InboxView() {
}
return accumulator;
}, {} );
+ const sortDirection = view.sort?.direction ?? 'desc';
+ const sortField = view.sort?.field ?? 'date';
const _queryArgs = {
per_page: view.perPage,
page: view.page,
- search: view.search,
..._filters,
status: statusFilter,
+ orderby: sortField,
+ order: sortDirection,
+ ...( view.search ? { search: view.search } : {} ),
};
// 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 ( ! isEqual( currentQuery, _queryArgs ) ) {
+ setCurrentQuery( _queryArgs );
+ }
+ }, [ view, statusFilter, setCurrentQuery, currentQuery ] );
const data = useMemo(
() =>
records?.map( record => ( {
@@ -329,7 +336,11 @@ export default function InboxView() {
getItemId={ getItemId }
defaultLayouts={ defaultLayouts }
header={ }
- empty={ }
+ empty={
+ isLoadingData ? null : (
+
+ )
+ }
/>
{
if ( action.type === RECEIVE_FILTERS ) {
return action.filters;
@@ -14,7 +24,7 @@ const filters = ( state = {}, action ) => {
return state;
};
-const currentQuery = ( state = {}, action ) => {
+const currentQuery = ( state = DEFAULT_QUERY, action ) => {
if ( action.type === SET_CURRENT_QUERY ) {
return action.currentQuery;
}
diff --git a/projects/packages/forms/tools/cleanup-test-feedback.php b/projects/packages/forms/tools/cleanup-test-feedback.php
new file mode 100644
index 0000000000000..b245278beaae9
--- /dev/null
+++ b/projects/packages/forms/tools/cleanup-test-feedback.php
@@ -0,0 +1,101 @@
+ null,
+ 'all' => false,
+);
+
+foreach ( $argv as $arg ) {
+ if ( strpos( $arg, '--status=' ) === 0 ) {
+ $args['status'] = str_replace( '--status=', '', $arg );
+ }
+ if ( $arg === '--all' ) {
+ $args['all'] = true;
+ }
+}
+
+// Build query args.
+$query_args = array(
+ 'post_type' => 'feedback',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+);
+
+if ( $args['all'] ) {
+ $query_args['post_status'] = array( 'publish', 'draft', 'spam', 'trash', 'pending', 'future' );
+ WP_CLI::log( 'Deleting ALL feedback entries...' );
+} elseif ( $args['status'] ) {
+ $query_args['post_status'] = $args['status'];
+ WP_CLI::log( sprintf( 'Deleting feedback entries with status "%s"...', $args['status'] ) );
+} else {
+ // Default: delete spam only.
+ $query_args['post_status'] = 'spam';
+ WP_CLI::log( 'Deleting spam feedback entries...' );
+}
+
+// Get all feedback IDs.
+$feedback_ids = get_posts( $query_args );
+
+if ( empty( $feedback_ids ) ) {
+ WP_CLI::success( 'No feedback entries found to delete.' );
+ return;
+}
+
+$total = count( $feedback_ids );
+WP_CLI::log( sprintf( 'Found %d feedback entries to delete.', $total ) );
+
+// Confirm deletion.
+WP_CLI::confirm( sprintf( 'Are you sure you want to delete %d feedback entries?', $total ) );
+
+$progress = \WP_CLI\Utils\make_progress_bar( 'Deleting feedback', $total );
+$deleted = 0;
+
+foreach ( $feedback_ids as $feedback_id ) {
+ $result = wp_delete_post( $feedback_id, true );
+ if ( $result ) {
+ ++$deleted;
+ }
+ $progress->tick();
+
+ // Free up memory periodically.
+ if ( $deleted % 100 === 0 ) {
+ wp_cache_flush();
+ }
+}
+
+$progress->finish();
+
+WP_CLI::success( sprintf( 'Successfully deleted %d feedback entries.', $deleted ) );
+
+// Also clean up any orphaned test pages.
+$test_pages = get_posts(
+ array(
+ 'post_type' => 'page',
+ 'title' => 'Test Contact Form Page',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ )
+);
+
+if ( ! empty( $test_pages ) ) {
+ WP_CLI::log( sprintf( 'Found %d test pages. Deleting...', count( $test_pages ) ) );
+ foreach ( $test_pages as $page_id ) {
+ wp_delete_post( $page_id, true );
+ }
+ WP_CLI::success( sprintf( 'Deleted %d test pages.', count( $test_pages ) ) );
+}
diff --git a/projects/packages/forms/tools/generate-test-feedback.php b/projects/packages/forms/tools/generate-test-feedback.php
new file mode 100644
index 0000000000000..0e7e808d9f5d5
--- /dev/null
+++ b/projects/packages/forms/tools/generate-test-feedback.php
@@ -0,0 +1,151 @@
+ 30000,
+ 'status' => 'spam',
+ 'batch' => 100,
+);
+
+foreach ( $argv as $arg ) {
+ if ( strpos( $arg, '--count=' ) === 0 ) {
+ $args['count'] = (int) str_replace( '--count=', '', $arg );
+ }
+ if ( strpos( $arg, '--status=' ) === 0 ) {
+ $args['status'] = str_replace( '--status=', '', $arg );
+ }
+ if ( strpos( $arg, '--batch=' ) === 0 ) {
+ $args['batch'] = (int) str_replace( '--batch=', '', $arg );
+ }
+}
+
+WP_CLI::log( sprintf( 'Generating %d feedback entries with status "%s"...', $args['count'], $args['status'] ) );
+
+// Create 10 test pages to be parents.
+$test_page_ids = array();
+$page_titles = array(
+ 'Contact Us Form',
+ 'Get a Quote',
+ 'Support Request',
+ 'Newsletter Signup',
+ 'Product Inquiry',
+ 'General Feedback',
+ 'Partnership Opportunities',
+ 'Event Registration',
+ 'Demo Request',
+ 'Sales Contact',
+);
+
+WP_CLI::log( 'Creating 10 test parent pages...' );
+
+foreach ( $page_titles as $index => $title ) {
+ $page_id = wp_insert_post(
+ array(
+ 'post_title' => $title,
+ 'post_content' => '',
+ 'post_status' => 'publish',
+ 'post_type' => 'page',
+ )
+ );
+
+ if ( is_wp_error( $page_id ) ) {
+ WP_CLI::error( 'Failed to create test page: ' . $page_id->get_error_message() );
+ return;
+ }
+
+ $test_page_ids[] = $page_id;
+ WP_CLI::log( sprintf( ' Created page %d/%d: "%s" (ID: %d)', $index + 1, count( $page_titles ), $title, $page_id ) );
+}
+
+WP_CLI::log( sprintf( 'Created %d test pages', count( $test_page_ids ) ) );
+
+// Sample data for variation.
+$first_names = array( 'John', 'Jane', 'Bob', 'Alice', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry' );
+$last_names = array( 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez' );
+$domains = array( 'example.com', 'test.com', 'demo.com', 'sample.org', 'mail.net' );
+$messages = array(
+ 'I am interested in your services.',
+ 'Please contact me about this opportunity.',
+ 'Can you provide more information?',
+ 'This is a test message.',
+ 'Looking forward to hearing from you.',
+);
+
+$progress = \WP_CLI\Utils\make_progress_bar( 'Generating feedback', $args['count'] );
+$created = 0;
+
+for ( $i = 0; $i < $args['count']; $i++ ) {
+ $first_name = $first_names[ array_rand( $first_names ) ];
+ $last_name = $last_names[ array_rand( $last_names ) ];
+ $name = $first_name . ' ' . $last_name;
+ $email = strtolower( $first_name . '.' . $last_name . $i ) . '@' . $domains[ array_rand( $domains ) ];
+ $message = $messages[ array_rand( $messages ) ];
+
+ // Randomly select a parent page.
+ $parent_id = $test_page_ids[ array_rand( $test_page_ids ) ];
+ $parent_title = get_the_title( $parent_id );
+
+ // Create feedback entry.
+ $feedback_data = array(
+ '1_Name' => $name,
+ '2_Email' => $email,
+ '3_Message' => $message,
+ );
+
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'feedback',
+ 'post_status' => $args['status'],
+ 'post_title' => sprintf( 'Contact Form: %s', $name ),
+ 'post_content' => wp_json_encode( $feedback_data ),
+ 'post_parent' => $parent_id,
+ 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '-' . rand( 1, 365 ) . ' days' ) ),
+ ),
+ true
+ );
+
+ if ( ! is_wp_error( $post_id ) ) {
+ // Add post meta for form response.
+ update_post_meta( $post_id, '_feedback_author', $name );
+ update_post_meta( $post_id, '_feedback_author_email', $email );
+ update_post_meta( $post_id, '_feedback_subject', 'Contact Form Submission' );
+ update_post_meta( $post_id, '_feedback_ip', '192.168.' . rand( 1, 255 ) . '.' . rand( 1, 255 ) );
+ update_post_meta( $post_id, '_feedback_entry_title', $parent_title );
+ update_post_meta( $post_id, '_feedback_entry_permalink', get_permalink( $parent_id ) );
+
+ ++$created;
+ }
+
+ $progress->tick();
+
+ // Free up memory periodically.
+ if ( $i % $args['batch'] === 0 ) {
+ wp_cache_flush();
+ }
+}
+
+$progress->finish();
+
+WP_CLI::success( sprintf( 'Successfully created %d feedback entries with status "%s"', $created, $args['status'] ) );
+WP_CLI::log( sprintf( 'Test page IDs: %s', implode( ', ', $test_page_ids ) ) );
+WP_CLI::log( '' );
+WP_CLI::log( 'To clean up test data, run:' );
+WP_CLI::log( ' wp eval-file cleanup-test-feedback.php' );
+WP_CLI::log( '' );
+WP_CLI::log( 'Or manually:' );
+WP_CLI::log( ' wp post delete $(wp post list --post_type=feedback --format=ids) --force' );
+WP_CLI::log( ' wp post delete ' . implode( ' ', $test_page_ids ) . ' --force' );