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