From 24a1af35f08336d5d3fd9e56fd38270dc7abb621 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Tue, 21 Jul 2026 13:15:00 +0530 Subject: [PATCH 01/13] feat: support XML and CSV exports for form submissions --- inc/plugins/class-dashboard.php | 116 ++++++++++++++++++++-- inc/plugins/class-form-records-export.php | 111 ++++++++++++++++++++- src/blocks/test/e2e/blocks/form.spec.js | 19 +++- tests/test-form-submissions.php | 88 +++++++++++++++- 4 files changed, 322 insertions(+), 12 deletions(-) diff --git a/inc/plugins/class-dashboard.php b/inc/plugins/class-dashboard.php index a3f4a8085..a43edc014 100644 --- a/inc/plugins/class-dashboard.php +++ b/inc/plugins/class-dashboard.php @@ -635,6 +635,54 @@ private function the_otter_banner() { max-height: 35px; } + .o-export-split { + position: relative; + display: inline-flex; + } + + .o-export-split #export-submissions { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .o-export-split__toggle { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-left: none !important; + padding: 0 6px !important; + } + + .o-export-split__menu { + position: absolute; + top: 100%; + right: 0; + z-index: 10; + margin: 4px 0 0; + padding: 4px 0; + list-style: none; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + min-width: 220px; + } + + .o-export-split__item { + display: block; + width: 100%; + padding: 8px 12px; + background: none; + border: none; + text-align: left; + cursor: pointer; + font-size: 13px; + } + + .o-export-split__item:hover, + .o-export-split__item:focus { + background: #f0f0f1; + } + .wp-core-ui .button.o-locked-action, .wp-core-ui .button.o-locked-action:focus { display: inline-flex; @@ -683,9 +731,25 @@ private function the_otter_banner() {

- +
+ + + +
@@ -706,7 +770,20 @@ class="button o-locked-action" export_csv() : $this->export_xml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + wp_die(); + } + + /** + * Build the WordPress WXR (XML) export of all submissions. + * + * @return string + */ + private function export_xml() { require_once ABSPATH . 'wp-admin/includes/export.php'; + ob_start(); export_wp( array( 'content' => Form_Submissions::FORM_RECORD_TYPE ) ); $export = ob_get_clean(); - echo ent2ncr( $export ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - wp_die(); + return ent2ncr( $export ); + } + + /** + * Build a CSV export of all submissions. + * + * @return string + */ + private function export_csv() { + $records = get_posts( + array( + 'post_type' => Form_Submissions::FORM_RECORD_TYPE, + 'post_status' => array( 'draft', 'unread', 'read', 'trash' ), + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + update_meta_cache( 'post', wp_list_pluck( $records, 'ID' ) ); + + $fixed_columns = array( + 'id' => __( 'ID', 'otter-blocks' ), + 'status' => __( 'Status', 'otter-blocks' ), + 'date' => __( 'Submission Date', 'otter-blocks' ), + 'form' => __( 'Form', 'otter-blocks' ), + 'post' => __( 'Post URL', 'otter-blocks' ), + ); + + $input_columns = array(); + $rows = array(); + + foreach ( $records as $record ) { + $post_id = $record->ID; + $meta = get_post_meta( $post_id, Form_Submissions::FORM_RECORD_META_KEY, true ); + + if ( ! is_array( $meta ) ) { + continue; + } + + $row = array( + 'id' => substr( strval( $post_id ), -8 ), + 'status' => get_post_status( $post_id ), + 'date' => get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post_id ), + 'form' => isset( $meta['form']['value'] ) ? $meta['form']['value'] : '', + 'post' => isset( $meta['post_url']['value'] ) ? $meta['post_url']['value'] : '', + ); + + $inputs = isset( $meta['inputs'] ) && is_array( $meta['inputs'] ) ? $meta['inputs'] : array(); + + foreach ( $inputs as $input ) { + if ( empty( $input ) || ! isset( $input['type'], $input['label'] ) || 'stripe-field' === $input['type'] ) { + continue; + } + + $label = $input['label']; + + if ( ! isset( $input_columns[ $label ] ) ) { + $input_columns[ $label ] = $label; + } + + $value = isset( $input['value'] ) ? $input['value'] : ''; + + if ( 'file' === $input['type'] && isset( $input['metadata']['name'] ) ) { + $value = $input['metadata']['name']; + } + + $row[ $label ] = $value; + } + + $rows[] = $row; + } + + $columns = array_merge( $fixed_columns, $input_columns ); + + $stream = fopen( 'php://temp', 'w+' ); + + fputcsv( $stream, array_values( $columns ) ); + + foreach ( $rows as $row ) { + $line = array(); + + foreach ( array_keys( $columns ) as $key ) { + $line[] = isset( $row[ $key ] ) ? $row[ $key ] : ''; + } + + fputcsv( $stream, $line ); + } + + rewind( $stream ); + $csv = stream_get_contents( $stream ); + fclose( $stream ); + + // Prefix a UTF-8 BOM so Excel detects the encoding instead of mangling accented characters. + return "\xEF\xBB\xBF" . $csv; } } diff --git a/src/blocks/test/e2e/blocks/form.spec.js b/src/blocks/test/e2e/blocks/form.spec.js index aec3dee3b..82815e4cf 100644 --- a/src/blocks/test/e2e/blocks/form.spec.js +++ b/src/blocks/test/e2e/blocks/form.spec.js @@ -412,10 +412,10 @@ test.describe( 'Form Block', () => { await expect( page.locator( '.otter-form-input[type="hidden"]' ) ).toHaveValue( '123' ); }); - test( 'can export form data', async({ page }) => { + test( 'can export form data as WordPress XML (WXR)', async({ page }) => { await page.goto( '/wp-admin/edit.php?post_type=otter_form_record' ); - const exportBtn = page.getByRole( 'button', { name: 'Export' }); + const exportBtn = page.getByRole( 'button', { name: 'Export', exact: true }); await expect( exportBtn ).toBeVisible(); @@ -425,5 +425,20 @@ test.describe( 'Form Block', () => { await download.path(); // Wait for download to complete. expect( download.suggestedFilename().startsWith( 'otter_form_submissions' ) ).toBeTruthy(); + expect( download.suggestedFilename().endsWith( '.xml' ) ).toBeTruthy(); + }); + + test( 'can export form data as CSV', async({ page }) => { + await page.goto( '/wp-admin/edit.php?post_type=otter_form_record' ); + + await page.locator( '#export-submissions-toggle' ).click(); + + const downloadPromise = page.waitForEvent( 'download' ); + await page.getByRole( 'button', { name: 'Export as CSV' }).click(); + const download = await downloadPromise; + + await download.path(); // Wait for download to complete. + expect( download.suggestedFilename().startsWith( 'otter_form_submissions' ) ).toBeTruthy(); + expect( download.suggestedFilename().endsWith( '.csv' ) ).toBeTruthy(); }); }); diff --git a/tests/test-form-submissions.php b/tests/test-form-submissions.php index 68a0aea73..1629833e9 100644 --- a/tests/test-form-submissions.php +++ b/tests/test-form-submissions.php @@ -58,7 +58,7 @@ public function set_up() { */ public function tear_down() { unset( $_REQUEST[ Form_Submissions::FORM_RECORD_TYPE ], $_REQUEST['_wpnonce'], $_REQUEST['post'] ); - unset( $_POST['action'], $_POST['_wpnonce'], $_POST['_nonce'] ); + unset( $_POST['action'], $_POST['_wpnonce'], $_POST['_nonce'], $_POST['format'] ); unset( $_GET['post_type'], $_GET['filter_action'], $_GET['filters_nonce'], $_GET['otter_form_filter'], $_REQUEST['otter_form_filter'] ); unset( $_GET['otter_post_filter'], $_REQUEST['otter_post_filter'] ); unset( $GLOBALS['otter_test_stripe_record_id'] ); @@ -809,6 +809,92 @@ public function test_export_requires_manage_options() { ( new Form_Records_Export() )->export_submissions(); } + /** + * Ensure the export defaults to the WordPress XML (WXR) format when no format is + * posted, preserving the pre-existing endpoint contract. + */ + public function test_export_defaults_to_xml_format() { + if ( ! \ThemeIsle\GutenbergBlocks\Pro::is_pro_installed() ) { + $this->markTestSkipped( 'Otter Pro is not installed in this test environment.' ); + } + + add_filter( 'product_otter_license_status', array( $this, 'valid_license' ) ); + + $this->create_record(); + + wp_set_current_user( $this->admin_id ); + $_POST['_nonce'] = wp_create_nonce( 'otter_form_export_submissions' ); + + ob_start(); + + try { + ( new Form_Records_Export() )->export_submissions(); + } catch ( WPDieException $exception ) { + // export_submissions() always ends in wp_die(); assertions run on the buffered output below. + } + + $output = ob_get_clean(); + + $this->assertStringContainsString( 'markTestSkipped( 'Otter Pro is not installed in this test environment.' ); + } + + add_filter( 'product_otter_license_status', array( $this, 'valid_license' ) ); + + $this->create_record( + 'unread', + array( + 'inputs' => array( + 'input01' => array( + 'label' => 'Name', + 'value' => 'Ada Lovelace', + 'type' => 'text', + ), + ), + ) + ); + + $this->create_record( + 'read', + array( + 'inputs' => array( + 'input02' => array( + 'label' => 'Company', + 'value' => 'Analytical Engines Ltd', + 'type' => 'text', + ), + ), + ) + ); + + wp_set_current_user( $this->admin_id ); + $_POST['_nonce'] = wp_create_nonce( 'otter_form_export_submissions' ); + $_POST['format'] = 'csv'; + + ob_start(); + + try { + ( new Form_Records_Export() )->export_submissions(); + } catch ( WPDieException $exception ) { + // export_submissions() always ends in wp_die(); assertions run on the buffered output below. + } + + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Name', $output ); + $this->assertStringContainsString( 'Company', $output ); + $this->assertStringContainsString( 'Ada Lovelace', $output ); + $this->assertStringContainsString( 'Analytical Engines Ltd', $output ); + } + /** * Ensure the list-table bulk actions match the current status view. */ From b359a3b3f8b8cd7c2825a4ef52669129aa17cc61 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Tue, 21 Jul 2026 14:39:13 +0530 Subject: [PATCH 02/13] fix: sanitize CSV output --- inc/plugins/class-dashboard.php | 2 +- inc/plugins/class-form-records-export.php | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/inc/plugins/class-dashboard.php b/inc/plugins/class-dashboard.php index a43edc014..038be1fba 100644 --- a/inc/plugins/class-dashboard.php +++ b/inc/plugins/class-dashboard.php @@ -739,7 +739,7 @@ private function the_otter_banner() { id="export-submissions-toggle" type="button" class="button o-export-split__toggle" - aria-haspopup="true" + aria-controls="export-submissions-menu" aria-expanded="false" aria-label="" > diff --git a/inc/plugins/class-form-records-export.php b/inc/plugins/class-form-records-export.php index 59fb098fa..cb4e35650 100644 --- a/inc/plugins/class-form-records-export.php +++ b/inc/plugins/class-form-records-export.php @@ -72,7 +72,7 @@ private function export_csv() { $records = get_posts( array( 'post_type' => Form_Submissions::FORM_RECORD_TYPE, - 'post_status' => array( 'draft', 'unread', 'read', 'trash' ), + 'post_status' => array( 'draft', 'unread', 'read', 'trash', 'publish' ), 'posts_per_page' => -1, 'orderby' => 'ID', 'order' => 'ASC', @@ -115,10 +115,11 @@ private function export_csv() { continue; } - $label = $input['label']; + $label = $input['label']; + $column_key = 'input:' . $label; if ( ! isset( $input_columns[ $label ] ) ) { - $input_columns[ $label ] = $label; + $input_columns[ $column_key ] = $label; } $value = isset( $input['value'] ) ? $input['value'] : ''; @@ -127,7 +128,7 @@ private function export_csv() { $value = $input['metadata']['name']; } - $row[ $label ] = $value; + $row[ $column_key ] = $value; } $rows[] = $row; @@ -137,13 +138,19 @@ private function export_csv() { $stream = fopen( 'php://temp', 'w+' ); - fputcsv( $stream, array_values( $columns ) ); + $sanitize_cell = static function ( $value ) { + $value = strval( $value ); + return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value; + }; + + fputcsv( $stream, array_map( $sanitize_cell, array_values( $columns ) ) ); foreach ( $rows as $row ) { $line = array(); foreach ( array_keys( $columns ) as $key ) { - $line[] = isset( $row[ $key ] ) ? $row[ $key ] : ''; + $value = isset( $row[ $key ] ) ? $row[ $key ] : ''; + $line[] = $sanitize_cell( $value ); } fputcsv( $stream, $line ); From ad6e823ca790d4053147898f168b10b167ee230a Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Tue, 21 Jul 2026 14:54:06 +0530 Subject: [PATCH 03/13] fix: phpcs --- inc/plugins/class-form-records-export.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/inc/plugins/class-form-records-export.php b/inc/plugins/class-form-records-export.php index cb4e35650..d777c70af 100644 --- a/inc/plugins/class-form-records-export.php +++ b/inc/plugins/class-form-records-export.php @@ -116,7 +116,7 @@ private function export_csv() { } $label = $input['label']; - $column_key = 'input:' . $label; + $column_key = 'input:' . $label; if ( ! isset( $input_columns[ $label ] ) ) { $input_columns[ $column_key ] = $label; @@ -136,24 +136,24 @@ private function export_csv() { $columns = array_merge( $fixed_columns, $input_columns ); - $stream = fopen( 'php://temp', 'w+' ); + $stream = fopen( 'php://temp', 'w+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $sanitize_cell = static function ( $value ) { - $value = strval( $value ); - return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value; - }; + $value = strval( $value ); + return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value; + }; - fputcsv( $stream, array_map( $sanitize_cell, array_values( $columns ) ) ); + fputcsv( $stream, array_map( $sanitize_cell, array_values( $columns ) ) ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv foreach ( $rows as $row ) { $line = array(); foreach ( array_keys( $columns ) as $key ) { $value = isset( $row[ $key ] ) ? $row[ $key ] : ''; - $line[] = $sanitize_cell( $value ); + $line[] = $sanitize_cell( $value ); } - fputcsv( $stream, $line ); + fputcsv( $stream, $line ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv } rewind( $stream ); From 1d3ae248f920eae80ae5daf0b9bb67de927bb516 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Wed, 29 Jul 2026 10:39:32 +0530 Subject: [PATCH 04/13] feat: enhance CSV export --- inc/plugins/class-form-records-export.php | 235 ++++++++++++++++------ tests/test-form-submissions.php | 58 ++++++ 2 files changed, 234 insertions(+), 59 deletions(-) diff --git a/inc/plugins/class-form-records-export.php b/inc/plugins/class-form-records-export.php index d777c70af..c02f38460 100644 --- a/inc/plugins/class-form-records-export.php +++ b/inc/plugins/class-form-records-export.php @@ -16,6 +16,13 @@ * Class Form_Records_Export */ class Form_Records_Export { + /** + * The number of submissions to fetch in each batch when exporting to CSV. + * + * @var int + */ + const EXPORT_BATCH_SIZE = 100; + /** * Register hooks. * @@ -44,7 +51,12 @@ public function export_submissions() { $format = isset( $_POST['format'] ) ? sanitize_key( wp_unslash( $_POST['format'] ) ) : 'xml'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended - echo 'csv' === $format ? $this->export_csv() : $this->export_xml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + if ( 'csv' === $format ) { + $this->export_csv(); + } else { + echo $this->export_xml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + wp_die(); } @@ -66,101 +78,206 @@ private function export_xml() { /** * Build a CSV export of all submissions. * - * @return string + * @return void */ private function export_csv() { - $records = get_posts( - array( - 'post_type' => Form_Submissions::FORM_RECORD_TYPE, - 'post_status' => array( 'draft', 'unread', 'read', 'trash', 'publish' ), - 'posts_per_page' => -1, - 'orderby' => 'ID', - 'order' => 'ASC', - ) + $columns = array_merge( $this->get_fixed_columns(), $this->collect_input_columns() ); + + $stream = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + + if ( false === $stream ) { + return; + } + + // Prefix a UTF-8 BOM so Excel detects the encoding instead of mangling accented characters. + fwrite( $stream, "\xEF\xBB\xBF" ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite + + fputcsv( $stream, array_map( array( $this, 'sanitize_cell' ), array_values( $columns ) ) ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv + + $column_keys = array_keys( $columns ); + + $this->walk_submissions( + function ( $meta, $record ) use ( $stream, $column_keys ) { + $row = $this->get_record_row( $record, $meta ); + $line = array(); + + foreach ( $column_keys as $key ) { + $line[] = $this->sanitize_cell( isset( $row[ $key ] ) ? $row[ $key ] : '' ); + } + + fputcsv( $stream, $line ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv + } ); - update_meta_cache( 'post', wp_list_pluck( $records, 'ID' ) ); + fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + } - $fixed_columns = array( + /** + * Columns present for every submission, in export order. + * + * @return array Column key => header label. + */ + private function get_fixed_columns() { + return array( 'id' => __( 'ID', 'otter-blocks' ), 'status' => __( 'Status', 'otter-blocks' ), 'date' => __( 'Submission Date', 'otter-blocks' ), 'form' => __( 'Form', 'otter-blocks' ), 'post' => __( 'Post URL', 'otter-blocks' ), ); + } + /** + * Collect the dynamically generated input columns found across every submission. + * + * @return array Column key => header label, in order of first appearance. + */ + private function collect_input_columns() { $input_columns = array(); - $rows = array(); - foreach ( $records as $record ) { - $post_id = $record->ID; - $meta = get_post_meta( $post_id, Form_Submissions::FORM_RECORD_META_KEY, true ); + $this->walk_submissions( + function ( $meta ) use ( &$input_columns ) { + foreach ( $this->get_record_inputs( $meta ) as $column_key => $input ) { + if ( isset( $input_columns[ $column_key ] ) ) { + continue; + } - if ( ! is_array( $meta ) ) { - continue; + $input_columns[ $column_key ] = $input['label']; + } } + ); - $row = array( - 'id' => substr( strval( $post_id ), -8 ), - 'status' => get_post_status( $post_id ), - 'date' => get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post_id ), - 'form' => isset( $meta['form']['value'] ) ? $meta['form']['value'] : '', - 'post' => isset( $meta['post_url']['value'] ) ? $meta['post_url']['value'] : '', - ); + return $input_columns; + } - $inputs = isset( $meta['inputs'] ) && is_array( $meta['inputs'] ) ? $meta['inputs'] : array(); + /** + * Run a callback over every submission, loading them in bounded batches. + * + * @param callable $callback Receives the submission record meta and the submission post. + * @return void + */ + private function walk_submissions( $callback ) { + $offset = 0; + $has_more_submissions = false; - foreach ( $inputs as $input ) { - if ( empty( $input ) || ! isset( $input['type'], $input['label'] ) || 'stripe-field' === $input['type'] ) { - continue; - } + do { + $records = $this->get_submissions_batch( $offset ); + $count = count( $records ); - $label = $input['label']; - $column_key = 'input:' . $label; + if ( 0 === $count ) { + return; + } - if ( ! isset( $input_columns[ $label ] ) ) { - $input_columns[ $column_key ] = $label; - } + $ids = wp_list_pluck( $records, 'ID' ); + update_meta_cache( 'post', $ids ); - $value = isset( $input['value'] ) ? $input['value'] : ''; + foreach ( $records as $record ) { + $meta = get_post_meta( $record->ID, Form_Submissions::FORM_RECORD_META_KEY, true ); - if ( 'file' === $input['type'] && isset( $input['metadata']['name'] ) ) { - $value = $input['metadata']['name']; + if ( ! is_array( $meta ) ) { + continue; } - $row[ $column_key ] = $value; + $callback( $meta, $record ); } - $rows[] = $row; - } + foreach ( $ids as $id ) { + wp_cache_delete( $id, 'posts' ); + wp_cache_delete( $id, 'post_meta' ); + } + + unset( $records, $ids ); + + $offset += $count; + $has_more_submissions = self::EXPORT_BATCH_SIZE === $count; + } while ( $has_more_submissions ); + } + + /** + * Fetch a batch of submissions, oldest first. + * + * @param int $offset How many submissions to skip. + * @return \WP_Post[] + */ + private function get_submissions_batch( $offset ) { + return get_posts( + array( + 'post_type' => Form_Submissions::FORM_RECORD_TYPE, + 'post_status' => array( 'draft', 'unread', 'read', 'trash', 'publish' ), + 'posts_per_page' => self::EXPORT_BATCH_SIZE, + 'offset' => $offset, + 'orderby' => 'ID', + 'order' => 'ASC', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + } + + /** + * Build the CSV row of a single submission, keyed by column. + * + * @param \WP_Post $record Submission post. + * @param array $meta Submission record meta. + * @return array + */ + private function get_record_row( $record, $meta ) { + $post_id = $record->ID; + + $row = array( + 'id' => substr( strval( $post_id ), -8 ), + 'status' => get_post_status( $post_id ), + 'date' => get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post_id ), + 'form' => isset( $meta['form']['value'] ) ? $meta['form']['value'] : '', + 'post' => isset( $meta['post_url']['value'] ) ? $meta['post_url']['value'] : '', + ); - $columns = array_merge( $fixed_columns, $input_columns ); + foreach ( $this->get_record_inputs( $meta ) as $column_key => $input ) { + $row[ $column_key ] = $input['value']; + } - $stream = fopen( 'php://temp', 'w+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + return $row; + } - $sanitize_cell = static function ( $value ) { - $value = strval( $value ); - return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value; - }; + /** + * Extract the exportable inputs of a submission, keyed by column. + * + * @param array $meta Submission record meta. + * @return array> Column key => array with the `label` and `value` of the input. + */ + private function get_record_inputs( $meta ) { + $inputs = isset( $meta['inputs'] ) && is_array( $meta['inputs'] ) ? $meta['inputs'] : array(); + $parsed = array(); - fputcsv( $stream, array_map( $sanitize_cell, array_values( $columns ) ) ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv + foreach ( $inputs as $input ) { + if ( empty( $input ) || ! isset( $input['type'], $input['label'] ) || 'stripe-field' === $input['type'] ) { + continue; + } - foreach ( $rows as $row ) { - $line = array(); + $value = isset( $input['value'] ) ? $input['value'] : ''; - foreach ( array_keys( $columns ) as $key ) { - $value = isset( $row[ $key ] ) ? $row[ $key ] : ''; - $line[] = $sanitize_cell( $value ); + if ( 'file' === $input['type'] && isset( $input['metadata']['name'] ) ) { + $value = $input['metadata']['name']; } - fputcsv( $stream, $line ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv + $parsed[ 'input:' . $input['label'] ] = array( + 'label' => $input['label'], + 'value' => $value, + ); } - rewind( $stream ); - $csv = stream_get_contents( $stream ); - fclose( $stream ); + return $parsed; + } - // Prefix a UTF-8 BOM so Excel detects the encoding instead of mangling accented characters. - return "\xEF\xBB\xBF" . $csv; + /** + * Sanitize a cell value for CSV export, prefixing with a single quote if it looks like a formula. + * + * @param mixed $value Cell value. + * @return string + */ + private function sanitize_cell( $value ) { + $value = strval( $value ); + + return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value; } } diff --git a/tests/test-form-submissions.php b/tests/test-form-submissions.php index 1629833e9..ede6a7c3a 100644 --- a/tests/test-form-submissions.php +++ b/tests/test-form-submissions.php @@ -895,6 +895,64 @@ public function test_export_csv_contains_submission_data() { $this->assertStringContainsString( 'Analytical Engines Ltd', $output ); } + /** + * Ensure the CSV export walks past the first batch: every submission gets a row, and a field + * that only shows up in a later batch still makes it into the header row. + */ + public function test_export_csv_paginates_beyond_a_single_batch() { + if ( ! \ThemeIsle\GutenbergBlocks\Pro::is_pro_installed() ) { + $this->markTestSkipped( 'Otter Pro is not installed in this test environment.' ); + } + + add_filter( 'product_otter_license_status', array( $this, 'valid_license' ) ); + + $total = Form_Records_Export::EXPORT_BATCH_SIZE + 5; + + for ( $index = 0; $index < $total; $index++ ) { + // Only the very last submission carries the late field, so it lands in a second batch. + $label = $index === $total - 1 ? 'Late Field' : 'Name'; + + $this->create_record( + 'unread', + array( + 'inputs' => array( + 'input01' => array( + 'label' => $label, + 'value' => 'Submission ' . $index, + 'type' => 'text', + ), + ), + ) + ); + } + + wp_set_current_user( $this->admin_id ); + $_POST['_nonce'] = wp_create_nonce( 'otter_form_export_submissions' ); + $_POST['format'] = 'csv'; + + ob_start(); + + try { + ( new Form_Records_Export() )->export_submissions(); + } catch ( WPDieException $exception ) { + // export_submissions() always ends in wp_die(); assertions run on the buffered output below. + } + + $output = ob_get_clean(); + + $lines = array_filter( explode( "\n", trim( $output ) ) ); + + // One header row plus one row per submission. + $this->assertCount( $total + 1, $lines ); + + // The header is built from a full pass, so a label from a later batch is not missed. + $this->assertStringContainsString( 'Late Field', reset( $lines ) ); + + $this->assertStringContainsString( 'Submission 0', $output ); + $this->assertStringContainsString( 'Submission ' . ( Form_Records_Export::EXPORT_BATCH_SIZE - 1 ), $output ); + $this->assertStringContainsString( 'Submission ' . ( $total - 1 ), $output ); + } + /** * Ensure the list-table bulk actions match the current status view. */ From e632e8e817f4afbb4553b8eb5176626786ac882a Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Wed, 29 Jul 2026 11:54:16 +0530 Subject: [PATCH 05/13] fix: enhance CSV export with max submission ID handling --- inc/plugins/class-form-records-export.php | 102 ++++++++++++++++------ 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/inc/plugins/class-form-records-export.php b/inc/plugins/class-form-records-export.php index c02f38460..59f249ca1 100644 --- a/inc/plugins/class-form-records-export.php +++ b/inc/plugins/class-form-records-export.php @@ -23,6 +23,13 @@ class Form_Records_Export { */ const EXPORT_BATCH_SIZE = 100; + /** + * Post statuses. + * + * @var string[] + */ + const EXPORT_POST_STATUSES = array( 'draft', 'unread', 'read', 'trash', 'publish' ); + /** * Register hooks. * @@ -76,12 +83,14 @@ private function export_xml() { } /** - * Build a CSV export of all submissions. + * Write a CSV export of all submissions to the output. * * @return void */ private function export_csv() { - $columns = array_merge( $this->get_fixed_columns(), $this->collect_input_columns() ); + $max_id = (int) $this->get_max_submission_id(); + + $columns = array_merge( $this->get_fixed_columns(), $this->collect_input_columns( $max_id ) ); $stream = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen @@ -97,8 +106,9 @@ private function export_csv() { $column_keys = array_keys( $columns ); $this->walk_submissions( - function ( $meta, $record ) use ( $stream, $column_keys ) { - $row = $this->get_record_row( $record, $meta ); + $max_id, + function ( $meta, $post_id ) use ( $stream, $column_keys ) { + $row = $this->get_record_row( $post_id, $meta ); $line = array(); foreach ( $column_keys as $key ) { @@ -130,12 +140,14 @@ private function get_fixed_columns() { /** * Collect the dynamically generated input columns found across every submission. * + * @param int $max_id Highest submission ID to export. * @return array Column key => header label, in order of first appearance. */ - private function collect_input_columns() { + private function collect_input_columns( $max_id ) { $input_columns = array(); $this->walk_submissions( + $max_id, function ( $meta ) use ( &$input_columns ) { foreach ( $this->get_record_inputs( $meta ) as $column_key => $input ) { if ( isset( $input_columns[ $column_key ] ) ) { @@ -153,22 +165,24 @@ function ( $meta ) use ( &$input_columns ) { /** * Run a callback over every submission, loading them in bounded batches. * - * @param callable $callback Receives the submission record meta and the submission post. + * @param int $max_id Highest submission ID to export. + * @param callable $callback Receives the submission record meta and the submission post ID. * @return void */ - private function walk_submissions( $callback ) { - $offset = 0; - $has_more_submissions = false; + private function walk_submissions( $max_id, $callback ) { + $last_id = 0; do { - $records = $this->get_submissions_batch( $offset ); - $count = count( $records ); + $records = $this->get_submissions_batch( $last_id, $max_id ); - if ( 0 === $count ) { + // The batch is only limited, never offset, so an empty one means the range is done. + if ( empty( $records ) ) { return; } - $ids = wp_list_pluck( $records, 'ID' ); + $ids = wp_list_pluck( $records, 'ID' ); + $last_id = (int) end( $ids ); + update_meta_cache( 'post', $ids ); foreach ( $records as $record ) { @@ -178,9 +192,10 @@ private function walk_submissions( $callback ) { continue; } - $callback( $meta, $record ); + $callback( $meta, $record->ID ); } + // Drop the batch from the object cache, otherwise memory grows with every batch. foreach ( $ids as $id ) { wp_cache_delete( $id, 'posts' ); wp_cache_delete( $id, 'post_meta' ); @@ -188,42 +203,75 @@ private function walk_submissions( $callback ) { unset( $records, $ids ); - $offset += $count; - $has_more_submissions = self::EXPORT_BATCH_SIZE === $count; + $has_more_submissions = $last_id < $max_id; } while ( $has_more_submissions ); } /** - * Fetch a batch of submissions, oldest first. + * The highest submission ID eligible for export. * - * @param int $offset How many submissions to skip. + * @return int + */ + private function get_max_submission_id() { + $ids = get_posts( + array( + 'post_type' => Form_Submissions::FORM_RECORD_TYPE, + 'post_status' => self::EXPORT_POST_STATUSES, + 'posts_per_page' => 1, + 'orderby' => 'ID', + 'order' => 'DESC', + 'fields' => 'ids', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + return empty( $ids ) ? 0 : reset( $ids ); + } + + /** + * Fetch the submissions following the last one handled, oldest first. + * + * @param int $last_id Highest submission ID handled so far. + * @param int $max_id Highest submission ID to export. * @return \WP_Post[] */ - private function get_submissions_batch( $offset ) { - return get_posts( + private function get_submissions_batch( $last_id, $max_id ) { + $keyset_clause = static function ( $where ) use ( $last_id, $max_id ) { + global $wpdb; + + return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d AND {$wpdb->posts}.ID <= %d", $last_id, $max_id ); + }; + + add_filter( 'posts_where', $keyset_clause ); + + $query = new \WP_Query( array( 'post_type' => Form_Submissions::FORM_RECORD_TYPE, - 'post_status' => array( 'draft', 'unread', 'read', 'trash', 'publish' ), + 'post_status' => self::EXPORT_POST_STATUSES, 'posts_per_page' => self::EXPORT_BATCH_SIZE, - 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', + 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, + 'suppress_filters' => false, ) ); + + remove_filter( 'posts_where', $keyset_clause ); + + return $query->posts; } /** * Build the CSV row of a single submission, keyed by column. * - * @param \WP_Post $record Submission post. - * @param array $meta Submission record meta. + * @param int $post_id Submission post ID. + * @param array $meta Submission record meta. * @return array */ - private function get_record_row( $record, $meta ) { - $post_id = $record->ID; - + private function get_record_row( $post_id, $meta ) { $row = array( 'id' => substr( strval( $post_id ), -8 ), 'status' => get_post_status( $post_id ), From 0bf12769265c56299bd4e9b4564af2c23804901d Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Wed, 29 Jul 2026 12:29:38 +0530 Subject: [PATCH 06/13] fix: optimize query performance by disabling cache results --- inc/plugins/class-form-records-export.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/inc/plugins/class-form-records-export.php b/inc/plugins/class-form-records-export.php index 59f249ca1..87bb65504 100644 --- a/inc/plugins/class-form-records-export.php +++ b/inc/plugins/class-form-records-export.php @@ -221,6 +221,7 @@ private function get_max_submission_id() { 'orderby' => 'ID', 'order' => 'DESC', 'fields' => 'ids', + 'cache_results' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) @@ -243,7 +244,17 @@ private function get_submissions_batch( $last_id, $max_id ) { return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d AND {$wpdb->posts}.ID <= %d", $last_id, $max_id ); }; + /* + * A split query fetches the IDs and then primes the post cache with a second query, which + * is wasted work here: the batch is read once and its caches are dropped straight after. + * That priming is not covered by `cache_results`, so it has to be turned off separately. + */ + $single_query = static function () { + return false; + }; + add_filter( 'posts_where', $keyset_clause ); + add_filter( 'split_the_query', $single_query ); $query = new \WP_Query( array( @@ -253,12 +264,14 @@ private function get_submissions_batch( $last_id, $max_id ) { 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, + 'cache_results' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'suppress_filters' => false, ) ); + remove_filter( 'split_the_query', $single_query ); remove_filter( 'posts_where', $keyset_clause ); return $query->posts; From 5ebc94f708e161e7ed6e6b10e52df3abeb50e8f8 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Wed, 29 Jul 2026 13:16:18 +0530 Subject: [PATCH 07/13] fix: refactor CSV and XML export --- inc/plugins/class-form-records-export.php | 55 +++++++++++------------ 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/inc/plugins/class-form-records-export.php b/inc/plugins/class-form-records-export.php index 87bb65504..439c619d9 100644 --- a/inc/plugins/class-form-records-export.php +++ b/inc/plugins/class-form-records-export.php @@ -47,7 +47,7 @@ public function export_submissions() { wp_die( esc_html( __( 'Exporting submissions requires Otter Pro.', 'otter-blocks' ) ) ); } - $nonce = isset( $_POST['_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $nonce = isset( $_POST['_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'otter_form_export_submissions' ) ) { wp_die( esc_html( __( 'Invalid nonce.', 'otter-blocks' ) ) ); } @@ -56,12 +56,14 @@ public function export_submissions() { wp_die( esc_html( __( 'You are not allowed to export submissions.', 'otter-blocks' ) ) ); } - $format = isset( $_POST['format'] ) ? sanitize_key( wp_unslash( $_POST['format'] ) ) : 'xml'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $format = isset( $_POST['format'] ) ? sanitize_key( wp_unslash( $_POST['format'] ) ) : 'xml'; + + $output = new \SplFileObject( 'php://output', 'w' ); if ( 'csv' === $format ) { - $this->export_csv(); + $this->export_csv( $output ); } else { - echo $this->export_xml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $this->export_xml( $output ); } wp_die(); @@ -70,56 +72,50 @@ public function export_submissions() { /** * Build the WordPress WXR (XML) export of all submissions. * - * @return string + * @param \SplFileObject $output Output stream. + * @return void */ - private function export_xml() { + private function export_xml( $output ) { require_once ABSPATH . 'wp-admin/includes/export.php'; ob_start(); export_wp( array( 'content' => Form_Submissions::FORM_RECORD_TYPE ) ); $export = ob_get_clean(); - return ent2ncr( $export ); + $output->fwrite( ent2ncr( $export ) ); } /** * Write a CSV export of all submissions to the output. * + * @param \SplFileObject $output Output stream. * @return void */ - private function export_csv() { + private function export_csv( $output ) { $max_id = (int) $this->get_max_submission_id(); $columns = array_merge( $this->get_fixed_columns(), $this->collect_input_columns( $max_id ) ); - $stream = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen - - if ( false === $stream ) { - return; - } - // Prefix a UTF-8 BOM so Excel detects the encoding instead of mangling accented characters. - fwrite( $stream, "\xEF\xBB\xBF" ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite + $output->fwrite( "\xEF\xBB\xBF" ); - fputcsv( $stream, array_map( array( $this, 'sanitize_cell' ), array_values( $columns ) ) ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv + $output->fputcsv( array_map( array( $this, 'sanitize_cell' ), array_values( $columns ) ) ); $column_keys = array_keys( $columns ); $this->walk_submissions( $max_id, - function ( $meta, $post_id ) use ( $stream, $column_keys ) { - $row = $this->get_record_row( $post_id, $meta ); + function ( $meta, $record ) use ( $output, $column_keys ) { + $row = $this->get_record_row( $record, $meta ); $line = array(); foreach ( $column_keys as $key ) { $line[] = $this->sanitize_cell( isset( $row[ $key ] ) ? $row[ $key ] : '' ); } - fputcsv( $stream, $line ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv + $output->fputcsv( $line ); } ); - - fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } /** @@ -166,7 +162,7 @@ function ( $meta ) use ( &$input_columns ) { * Run a callback over every submission, loading them in bounded batches. * * @param int $max_id Highest submission ID to export. - * @param callable $callback Receives the submission record meta and the submission post ID. + * @param callable $callback Receives the submission record meta and the submission post. * @return void */ private function walk_submissions( $max_id, $callback ) { @@ -192,12 +188,11 @@ private function walk_submissions( $max_id, $callback ) { continue; } - $callback( $meta, $record->ID ); + $callback( $meta, $record ); } // Drop the batch from the object cache, otherwise memory grows with every batch. foreach ( $ids as $id ) { - wp_cache_delete( $id, 'posts' ); wp_cache_delete( $id, 'post_meta' ); } @@ -280,15 +275,15 @@ private function get_submissions_batch( $last_id, $max_id ) { /** * Build the CSV row of a single submission, keyed by column. * - * @param int $post_id Submission post ID. - * @param array $meta Submission record meta. + * @param \WP_Post $record Submission post. + * @param array $meta Submission record meta. * @return array */ - private function get_record_row( $post_id, $meta ) { + private function get_record_row( $record, $meta ) { $row = array( - 'id' => substr( strval( $post_id ), -8 ), - 'status' => get_post_status( $post_id ), - 'date' => get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post_id ), + 'id' => substr( strval( $record->ID ), -8 ), + 'status' => get_post_status( $record ), + 'date' => get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $record ), 'form' => isset( $meta['form']['value'] ) ? $meta['form']['value'] : '', 'post' => isset( $meta['post_url']['value'] ) ? $meta['post_url']['value'] : '', ); From cb1d14f30039b615f52eee6c9de760b4728175c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:45:22 +0000 Subject: [PATCH 08/13] build(deps): bump codeinwp/themeisle-sdk from 3.3.54 to 3.3.58 Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.54 to 3.3.58. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Changelog](https://github.com/Codeinwp/themeisle-sdk/blob/v3.3.58/CHANGELOG.md) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.54...v3.3.58) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.58 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- composer.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.lock b/composer.lock index 0d8f0c84d..c3be5643a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b41cfd1bb81a9d4f8ed32b2381a22d65", + "content-hash": "556f279ce85f25f708f60329f1f7067c", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.54", + "version": "3.3.58", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1" + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", "shasum": "" }, "require-dev": { @@ -36,16 +36,16 @@ "homepage": "https://themeisle.com" } ], - "description": "Themeisle SDK.", + "description": "Themeisle SDK library.", "homepage": "https://github.com/Codeinwp/themeisle-sdk", "keywords": [ "wordpress" ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" }, - "time": "2026-06-23T13:43:47+00:00" + "time": "2026-07-29T08:38:52+00:00" }, { "name": "enshrined/svg-sanitize", From 5e42f8e47b793c0e9e4f1b83628c5d5c47558d54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:45:21 +0000 Subject: [PATCH 09/13] build(deps): bump codeinwp/themeisle-sdk in /plugins/blocks-css Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.54 to 3.3.58. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Changelog](https://github.com/Codeinwp/themeisle-sdk/blob/v3.3.58/CHANGELOG.md) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.54...v3.3.58) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.58 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- plugins/blocks-css/composer.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/blocks-css/composer.lock b/plugins/blocks-css/composer.lock index 215845bb9..c2a3c2df6 100644 --- a/plugins/blocks-css/composer.lock +++ b/plugins/blocks-css/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.54", + "version": "3.3.58", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1" + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", "shasum": "" }, "require-dev": { @@ -36,16 +36,16 @@ "homepage": "https://themeisle.com" } ], - "description": "Themeisle SDK.", + "description": "Themeisle SDK library.", "homepage": "https://github.com/Codeinwp/themeisle-sdk", "keywords": [ "wordpress" ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" }, - "time": "2026-06-23T13:43:47+00:00" + "time": "2026-07-29T08:38:52+00:00" } ], "packages-dev": [], From d07ea2cab88a44d2df1525b492d7f34f669f18f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:45:23 +0000 Subject: [PATCH 10/13] build(deps): bump codeinwp/themeisle-sdk in /plugins/blocks-animation Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.54 to 3.3.58. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Changelog](https://github.com/Codeinwp/themeisle-sdk/blob/v3.3.58/CHANGELOG.md) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.54...v3.3.58) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.58 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- plugins/blocks-animation/composer.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/blocks-animation/composer.lock b/plugins/blocks-animation/composer.lock index b11e5749e..d03d95990 100644 --- a/plugins/blocks-animation/composer.lock +++ b/plugins/blocks-animation/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.54", + "version": "3.3.58", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1" + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", "shasum": "" }, "require-dev": { @@ -36,16 +36,16 @@ "homepage": "https://themeisle.com" } ], - "description": "Themeisle SDK.", + "description": "Themeisle SDK library.", "homepage": "https://github.com/Codeinwp/themeisle-sdk", "keywords": [ "wordpress" ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" }, - "time": "2026-06-23T13:43:47+00:00" + "time": "2026-07-29T08:38:52+00:00" } ], "packages-dev": [], From 476104b7f39e1ef80ba53de557ab143a5e112cba Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 6 Aug 2026 19:25:18 +0530 Subject: [PATCH 11/13] fix: ensure dynamic block renderer class exists before instantiation --- inc/class-registration.php | 2 +- tests/test-registration.php | 139 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/inc/class-registration.php b/inc/class-registration.php index 759c15824..fff5da32a 100644 --- a/inc/class-registration.php +++ b/inc/class-registration.php @@ -968,7 +968,7 @@ public function register_blocks() { ); } - if ( isset( $dynamic_blocks[ $block ] ) ) { + if ( isset( $dynamic_blocks[ $block ] ) && class_exists( $dynamic_blocks[ $block ] ) ) { $classname = $dynamic_blocks[ $block ]; $renderer = new $classname(); diff --git a/tests/test-registration.php b/tests/test-registration.php index 0f0087b05..81f458a4d 100644 --- a/tests/test-registration.php +++ b/tests/test-registration.php @@ -13,12 +13,30 @@ */ class Test_Registration extends WP_UnitTestCase { + /** + * Temp renderer files to remove after the test, even if it failed part way. + * + * @var array + */ + private $temp_renderer_files = array(); + /** * Tear down test environment. */ public function tear_down() { delete_option( 'themeisle_blocks_settings_global_defaults' ); + // Restore permissions before unlinking: a test that fails mid-way would + // otherwise leave an unreadable file behind and poison later runs. + foreach ( $this->temp_renderer_files as $file ) { + if ( file_exists( $file ) ) { + chmod( $file, 0644 ); + unlink( $file ); + } + } + + $this->temp_renderer_files = array(); + parent::tear_down(); } @@ -61,4 +79,125 @@ public function test_editor_global_defaults_is_object_when_option_is_missing() { $this->assertEquals( new stdClass(), Registration::get_editor_global_defaults() ); } + + /** + * Run register_blocks() with `form-captcha` mapped to $classname. + * + * register_blocks() already ran once via the `init` hook fired during + * bootstrap, so the plugin's blocks are unregistered first to keep this a + * clean registration instead of "already registered" notices. + * + * @param string $classname Renderer class to map form-captcha to. + * @return void + */ + private function register_blocks_with_captcha_renderer( $classname ) { + $filter = function ( $dynamic_blocks ) use ( $classname ) { + $dynamic_blocks['form-captcha'] = $classname; + + return $dynamic_blocks; + }; + + add_filter( 'otter_blocks_register_dynamic_blocks', $filter ); + + $registry = \WP_Block_Type_Registry::get_instance(); + + foreach ( array_keys( $registry->get_all_registered() ) as $name ) { + if ( 0 === strpos( $name, 'themeisle-blocks/' ) ) { + $registry->unregister( $name ); + } + } + + // WordPress does not convert PHP warnings into exceptions the way this + // suite is configured to; swallow the failed-include warning so the + // production code path is what gets exercised. + set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_handler + function () { + return true; + } + ); + + ( new Registration() )->register_blocks(); + + restore_error_handler(); + + remove_filter( 'otter_blocks_register_dynamic_blocks', $filter ); + } + + /** + * Register a throwaway autoloader that resolves $class from $file the same + * way Composer's classmap loader does: an unsuppressed `include` of the + * mapped path, with no prior existence check. + * + * @param string $class Class to map. + * @param string $file Path to include for it. + * @return callable The loader, for spl_autoload_unregister. + */ + private function register_composer_like_loader( $class, $file ) { + $loader = function ( $requested ) use ( $class, $file ) { + if ( ltrim( $class, '\\' ) === $requested ) { + include $file; // phpcs:ignore + } + }; + + spl_autoload_register( $loader ); + + return $loader; + } + + /** + * A dynamic block whose mapped renderer class has no autoload entry at all + * must not fatal registration for every other block; it should fall back to + * plain metadata registration. + */ + public function test_register_blocks_survives_dynamic_renderer_class_with_no_autoload_entry() { + $this->register_blocks_with_captcha_renderer( '\ThemeIsle\GutenbergBlocks\Render\Nonexistent_Form_Captcha_Block' ); + + $this->assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'themeisle-blocks/form-captcha' ) ); + } + + /** + * A renderer class that is mapped by the autoloader but whose file is absent + * from the deployed artifact must degrade the same way. Composer's classmap + * returns the path without checking it exists, so the include only warns and + * the class stays undefined. + */ + public function test_register_blocks_survives_dynamic_renderer_class_whose_file_is_missing() { + $class = 'ThemeIsle\GutenbergBlocks\Render\Missing_File_Captcha_Block'; + $loader = $this->register_composer_like_loader( $class, get_temp_dir() . 'otter-absent-renderer.php' ); + + $this->register_blocks_with_captcha_renderer( $class ); + + spl_autoload_unregister( $loader ); + + $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' ); + $this->assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'themeisle-blocks/form-captcha' ) ); + } + + /** + * Same degradation when the renderer file is present but not readable — a + * permissions mishap during deploy. `include` needs read permission, so the + * class again stays undefined. + */ + public function test_register_blocks_survives_dynamic_renderer_class_whose_file_is_unreadable() { + $class = 'ThemeIsle\GutenbergBlocks\Render\Unreadable_File_Captcha_Block'; + $file = get_temp_dir() . 'otter-unreadable-renderer.php'; + + $this->temp_renderer_files[] = $file; + + file_put_contents( $file, 'markTestSkipped( 'Cannot make a file unreadable as this user (likely running as root).' ); + } + + $loader = $this->register_composer_like_loader( $class, $file ); + + $this->register_blocks_with_captcha_renderer( $class ); + + spl_autoload_unregister( $loader ); + + $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' ); + $this->assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'themeisle-blocks/form-captcha' ) ); + } } From ca291651837802792c1e45c18cdaa9bc50bc3c61 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Fri, 7 Aug 2026 10:44:44 +0530 Subject: [PATCH 12/13] fix: assertions in block registration tests --- tests/test-registration.php | 71 +++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/tests/test-registration.php b/tests/test-registration.php index 81f458a4d..4292af4bd 100644 --- a/tests/test-registration.php +++ b/tests/test-registration.php @@ -110,17 +110,18 @@ private function register_blocks_with_captcha_renderer( $classname ) { // WordPress does not convert PHP warnings into exceptions the way this // suite is configured to; swallow the failed-include warning so the // production code path is what gets exercised. - set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_handler - function () { - return true; - } + set_error_handler( + function ( $error_level, $message ) { + return E_WARNING === $error_level && false !== strpos( $message, 'renderer.php' ); + } ); - ( new Registration() )->register_blocks(); - - restore_error_handler(); - - remove_filter( 'otter_blocks_register_dynamic_blocks', $filter ); + try { + ( new Registration() )->register_blocks(); + } finally { + restore_error_handler(); + remove_filter( 'otter_blocks_register_dynamic_blocks', $filter ); + } } /** @@ -135,7 +136,7 @@ function () { private function register_composer_like_loader( $class, $file ) { $loader = function ( $requested ) use ( $class, $file ) { if ( ltrim( $class, '\\' ) === $requested ) { - include $file; // phpcs:ignore + include $file; } }; @@ -144,6 +145,20 @@ private function register_composer_like_loader( $class, $file ) { return $loader; } + /** + * Positive control: a loadable renderer must register the block *with* a + * render_callback. Without this, the null-callback assertions below would + * pass even if registration silently stopped wiring renderers altogether. + */ + public function test_register_blocks_uses_the_renderer_when_the_class_is_loadable() { + $this->register_blocks_with_captcha_renderer( '\ThemeIsle\GutenbergBlocks\Render\Form_Captcha_Block' ); + + $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'themeisle-blocks/form-captcha' ); + + $this->assertNotNull( $block_type, 'The block must be registered.' ); + $this->assertIsCallable( $block_type->render_callback, 'A loadable renderer must be wired as the render callback.' ); + } + /** * A dynamic block whose mapped renderer class has no autoload entry at all * must not fatal registration for every other block; it should fall back to @@ -152,7 +167,21 @@ private function register_composer_like_loader( $class, $file ) { public function test_register_blocks_survives_dynamic_renderer_class_with_no_autoload_entry() { $this->register_blocks_with_captcha_renderer( '\ThemeIsle\GutenbergBlocks\Render\Nonexistent_Form_Captcha_Block' ); - $this->assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'themeisle-blocks/form-captcha' ) ); + $this->assertCaptchaRegisteredWithoutRenderer(); + } + + /** + * Assert the captcha block came through the fallback branch: still registered, + * but with no render_callback. `is_registered()` alone would also pass on the + * dynamic path, so the null callback is what pins the fallback down. + * + * @return void + */ + private function assertCaptchaRegisteredWithoutRenderer() { + $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'themeisle-blocks/form-captcha' ); + + $this->assertNotNull( $block_type, 'The block must still be registered.' ); + $this->assertNull( $block_type->render_callback, 'The block must fall back to registration without a renderer.' ); } /** @@ -165,12 +194,15 @@ public function test_register_blocks_survives_dynamic_renderer_class_whose_file_ $class = 'ThemeIsle\GutenbergBlocks\Render\Missing_File_Captcha_Block'; $loader = $this->register_composer_like_loader( $class, get_temp_dir() . 'otter-absent-renderer.php' ); - $this->register_blocks_with_captcha_renderer( $class ); + try { + $this->register_blocks_with_captcha_renderer( $class ); + } finally { + spl_autoload_unregister( $loader ); + } - spl_autoload_unregister( $loader ); $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' ); - $this->assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'themeisle-blocks/form-captcha' ) ); + $this->assertCaptchaRegisteredWithoutRenderer(); } /** @@ -184,7 +216,7 @@ public function test_register_blocks_survives_dynamic_renderer_class_whose_file_ $this->temp_renderer_files[] = $file; - file_put_contents( $file, 'register_composer_like_loader( $class, $file ); - $this->register_blocks_with_captcha_renderer( $class ); + try { + $this->register_blocks_with_captcha_renderer( $class ); + } finally { + spl_autoload_unregister( $loader ); + } - spl_autoload_unregister( $loader ); $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' ); - $this->assertTrue( \WP_Block_Type_Registry::get_instance()->is_registered( 'themeisle-blocks/form-captcha' ) ); + $this->assertCaptchaRegisteredWithoutRenderer(); } } From 5e6da550f323837f4df61f4b954f3f0b3d102b4f Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Fri, 7 Aug 2026 11:56:17 +0530 Subject: [PATCH 13/13] fix: accept expected include failure parameter --- tests/test-registration.php | 59 +++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/tests/test-registration.php b/tests/test-registration.php index 4292af4bd..d12b2983c 100644 --- a/tests/test-registration.php +++ b/tests/test-registration.php @@ -87,10 +87,11 @@ public function test_editor_global_defaults_is_object_when_option_is_missing() { * bootstrap, so the plugin's blocks are unregistered first to keep this a * clean registration instead of "already registered" notices. * - * @param string $classname Renderer class to map form-captcha to. + * @param string $classname Renderer class to map form-captcha to. + * @param string $expected_include_failure Path whose failed include is expected, or empty if no include failure is expected. * @return void */ - private function register_blocks_with_captcha_renderer( $classname ) { + private function register_blocks_with_captcha_renderer( $classname, $expected_include_failure = '' ) { $filter = function ( $dynamic_blocks ) use ( $classname ) { $dynamic_blocks['form-captcha'] = $classname; @@ -107,21 +108,30 @@ private function register_blocks_with_captcha_renderer( $classname ) { } } - // WordPress does not convert PHP warnings into exceptions the way this - // suite is configured to; swallow the failed-include warning so the - // production code path is what gets exercised. - set_error_handler( - function ( $error_level, $message ) { - return E_WARNING === $error_level && false !== strpos( $message, 'renderer.php' ); - } + $previous = set_error_handler( + function ( $level, $message, $file = '', $line = 0 ) use ( &$previous, $expected_include_failure ) { + if ( + '' !== $expected_include_failure && + E_WARNING === $level && + false !== strpos( $message, $expected_include_failure ) + ) { + return true; + } + + if ( null === $previous ) { + return false; + } + + return call_user_func( $previous, $level, $message, $file, $line ); + } ); try { - ( new Registration() )->register_blocks(); - } finally { - restore_error_handler(); - remove_filter( 'otter_blocks_register_dynamic_blocks', $filter ); - } + ( new Registration() )->register_blocks(); + } finally { + restore_error_handler(); + remove_filter( 'otter_blocks_register_dynamic_blocks', $filter ); + } } /** @@ -192,14 +202,14 @@ private function assertCaptchaRegisteredWithoutRenderer() { */ public function test_register_blocks_survives_dynamic_renderer_class_whose_file_is_missing() { $class = 'ThemeIsle\GutenbergBlocks\Render\Missing_File_Captcha_Block'; - $loader = $this->register_composer_like_loader( $class, get_temp_dir() . 'otter-absent-renderer.php' ); + $file = get_temp_dir() . 'otter-absent-renderer.php'; + $loader = $this->register_composer_like_loader( $class, $file ); try { - $this->register_blocks_with_captcha_renderer( $class ); - } finally { - spl_autoload_unregister( $loader ); - } - + $this->register_blocks_with_captcha_renderer( $class, $file ); + } finally { + spl_autoload_unregister( $loader ); + } $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' ); $this->assertCaptchaRegisteredWithoutRenderer(); @@ -226,11 +236,10 @@ public function test_register_blocks_survives_dynamic_renderer_class_whose_file_ $loader = $this->register_composer_like_loader( $class, $file ); try { - $this->register_blocks_with_captcha_renderer( $class ); - } finally { - spl_autoload_unregister( $loader ); - } - + $this->register_blocks_with_captcha_renderer( $class, $file ); + } finally { + spl_autoload_unregister( $loader ); + } $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' ); $this->assertCaptchaRegisteredWithoutRenderer();