From 06c5d764877f97ea89d4d8f74bd929f904ef70c1 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Mon, 22 Sep 2025 21:58:37 -0300 Subject: [PATCH 01/18] Use rest endpoint instead of ajax --- .../forms/changelog/rest-export-endpoint | 4 + .../class-contact-form-endpoint.php | 139 ++++++++++++++++++ .../class-contact-form-plugin.php | 15 +- .../dashboard/hooks/use-export-responses.ts | 52 ++++--- 4 files changed, 180 insertions(+), 30 deletions(-) create mode 100644 projects/packages/forms/changelog/rest-export-endpoint diff --git a/projects/packages/forms/changelog/rest-export-endpoint b/projects/packages/forms/changelog/rest-export-endpoint new file mode 100644 index 0000000000000..10e4b0f83db86 --- /dev/null +++ b/projects/packages/forms/changelog/rest-export-endpoint @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add REST API endpoint for exporting form responses, replacing legacy AJAX implementation 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 7a613264da59e..25aafc1676e1a 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 @@ -255,6 +255,43 @@ public function register_routes() { 'callback' => array( $this, 'get_forms_config' ), ) ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/export', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'permission_callback' => array( $this, 'export_permissions_check' ), + 'callback' => array( $this, 'export_responses' ), + 'args' => array( + 'selected' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'default' => array(), + ), + 'post' => array( + 'type' => 'string', + 'default' => 'all', + ), + 'search' => array( + 'type' => 'string', + 'default' => '', + ), + 'status' => array( + 'type' => 'string', + 'default' => 'publish', + ), + 'before' => array( + 'type' => 'string', + 'default' => '', + ), + 'after' => array( + 'type' => 'string', + 'default' => '', + ), + ), + ) + ); } /** @@ -1070,4 +1107,106 @@ public function get_forms_config( WP_REST_Request $request ) { // phpcs:ignore V return rest_ensure_response( $config ); } + + /** + * Checks if a given request has permissions to export responses. + * + * @param WP_REST_Request $request The request object. + * @return bool|WP_Error True if the request can export, error object otherwise. + */ + public function export_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( is_super_admin() ) { + return true; + } + + if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) { + return new WP_Error( + 'rest_user_not_member', + __( 'Sorry, you are not a member of this site.', 'jetpack-forms' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! current_user_can( 'export' ) ) { + return new WP_Error( + 'rest_user_cannot_export', + __( 'Sorry, you are not allowed to export form responses on this site.', 'jetpack-forms' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Export form responses to CSV. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response|WP_Error The response containing CSV data or error. + */ + public function export_responses( $request ) { + $selected = $request->get_param( 'selected' ); + $post_id = $request->get_param( 'post' ); + $search = $request->get_param( 'search' ); + $status = $request->get_param( 'status' ); + $before = $request->get_param( 'before' ); + $after = $request->get_param( 'after' ); + + $query_args = array( + 'post_type' => 'feedback', + 'posts_per_page' => -1, + 'post_status' => array( 'publish' ), + 'order' => 'ASC', + 'suppress_filters' => false, + ); + + if ( $status && $status !== 'publish' ) { + $query_args['post_status'] = explode( ',', $status ); + } + + if ( $post_id && $post_id !== 'all' ) { + $query_args['post_parent'] = intval( $post_id ); + } + + if ( $search ) { + $query_args['s'] = $search; + } + + if ( ! empty( $selected ) ) { + $query_args['post__in'] = array_map( 'intval', $selected ); + } + + if ( $before || $after ) { + $date_query = array(); + if ( $before ) { + $date_query['before'] = $before; + } + if ( $after ) { + $date_query['after'] = $after; + } + $query_args['date_query'] = array( $date_query ); + } + + $feedback_posts = get_posts( $query_args ); + + if ( empty( $feedback_posts ) ) { + return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) ); + } + + $feedback_ids = array_map( + function ( $post ) { + return $post->ID; + }, + $feedback_posts + ); + + $plugin = Contact_Form_Plugin::init(); + $export_data = $plugin->get_export_feedback_data( $feedback_ids ); + + if ( empty( $export_data ) ) { + return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) ); + } + + $plugin->download_feedback_as_csv( $export_data, $post_id ); + } } 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 86dca7d957d1f..1022fb3fa15e3 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 @@ -211,9 +211,7 @@ protected function __construct() { add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_personal_data_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) ); - // Export to CSV feature if ( is_admin() ) { - add_action( 'wp_ajax_feedback_export', array( $this, 'download_feedback_as_csv' ) ); add_action( 'wp_ajax_create_new_form', array( $this, 'create_new_form' ) ); } add_action( 'admin_menu', array( $this, 'admin_menu' ) ); @@ -2758,21 +2756,20 @@ function ( $selected ) { /** * Download exported data as CSV + * + * @param array $data Export data to generate CSV from. + * @param string $post_id Optional. Post ID for filename generation. */ - public function download_feedback_as_csv() { - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verification is done on get_feedback_entries_from_post function - $post_data = wp_unslash( $_POST ); - $data = $this->get_feedback_entries_from_post(); - + public function download_feedback_as_csv( $data, $post_id = '' ) { if ( empty( $data ) ) { return; } // Check if we want to download all the feedbacks or just a certain contact form - if ( ! empty( $post_data['post'] ) && $post_data['post'] !== 'all' ) { + if ( ! empty( $post_id ) && $post_id !== 'all' ) { $filename = sprintf( '%s - %s.csv', - Util::get_export_filename( get_the_title( (int) $post_data['post'] ) ), + Util::get_export_filename( get_the_title( (int) $post_id ) ), gmdate( 'Y-m-d H:i' ) ); } else { diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index f5ca2c5fd6a97..05de76d25c5d8 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -3,6 +3,7 @@ */ import jetpackAnalytics from '@automattic/jetpack-analytics'; import { useBreakpointMatch } from '@automattic/jetpack-components'; +import apiFetch from '@wordpress/api-fetch'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; import { useState, useCallback, useEffect } from '@wordpress/element'; @@ -10,16 +11,24 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { config } from '..'; import { store as dashboardStore } from '../store'; +type ExportData = { + selected: number[]; + post: string; + search: string; + status: string; + before?: string; + after?: string; +}; + type ExportHookReturn = { showExportModal: boolean; openModal: () => void; closeModal: () => void; autoConnectGdrive: boolean; userCanExport: boolean; - onExport: ( action: string, nonceName: string ) => Promise< Response >; + onExport: () => Promise< Response >; selectedResponsesCount: number; currentStatus: string; exportLabel: string; @@ -75,25 +84,26 @@ export default function useExportResponses(): ExportHookReturn { return { selected: getSelectedResponsesFromCurrentDataset(), currentQuery: getCurrentQuery() }; }, [] ); - const onExport = useCallback( - ( action: string, nonceName: string ) => { - const data = new FormData(); - data.append( 'action', action ); - data.append( nonceName, config( 'exportNonce' ) ); - selected.forEach( ( id: string ) => data.append( 'selected[]', id ) ); - data.append( 'post', currentQuery.parent || 'all' ); - data.append( 'search', currentQuery.search || '' ); - data.append( 'status', currentQuery.status ); - - if ( currentQuery.before && currentQuery.after ) { - data.append( 'before', currentQuery.before ); - data.append( 'after', currentQuery.after ); - } - - return fetch( window.ajaxurl, { method: 'POST', body: data } ); - }, - [ currentQuery, selected ] - ); + const onExport = useCallback( () => { + const exportData: ExportData = { + selected: selected.map( id => parseInt( id, 10 ) ), + post: currentQuery.parent ? String( currentQuery.parent ) : 'all', + search: currentQuery.search || '', + status: currentQuery.status || 'publish', + }; + + if ( currentQuery.before && currentQuery.after ) { + exportData.before = currentQuery.before; + exportData.after = currentQuery.after; + } + + return apiFetch( { + path: '/wp/v2/feedback/export', + method: 'POST', + data: exportData, + parse: false, + } ); + }, [ currentQuery, selected ] ); useEffect( () => { const url = new URL( window.location.href ); From f28cd8ad4b7c5f18f62717f0ee8659e0a0a6ae58 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Tue, 23 Sep 2025 19:11:07 -0300 Subject: [PATCH 02/18] not deprecating --- .../forms/src/contact-form/class-contact-form-plugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1022fb3fa15e3..fd600f259a864 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 @@ -2760,7 +2760,7 @@ function ( $selected ) { * @param array $data Export data to generate CSV from. * @param string $post_id Optional. Post ID for filename generation. */ - public function download_feedback_as_csv( $data, $post_id = '' ) { + public function download_feedback_as_csv( $data = null, $post_id = '' ) { if ( empty( $data ) ) { return; } From 258fed20159ffca3b8dc9976518a22e163c9a540 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 24 Sep 2025 09:37:18 -0300 Subject: [PATCH 03/18] Refactor CSV export to use admin-post --- .../class-contact-form-endpoint.php | 28 ++++++++++++--- .../class-contact-form-plugin.php | 35 +++++++++++++++++++ .../dashboard/hooks/use-export-responses.ts | 12 +++++-- 3 files changed, 67 insertions(+), 8 deletions(-) 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 25aafc1676e1a..ec36dbc7a3658 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 @@ -1200,13 +1200,31 @@ function ( $post ) { $feedback_posts ); - $plugin = Contact_Form_Plugin::init(); - $export_data = $plugin->get_export_feedback_data( $feedback_ids ); - - if ( empty( $export_data ) ) { + if ( empty( $feedback_ids ) ) { return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) ); } - $plugin->download_feedback_as_csv( $export_data, $post_id ); + $nonce = wp_create_nonce( 'feedback_export_' . implode( ',', $feedback_ids ) ); + + $download_url = add_query_arg( + array( + 'action' => 'feedback_export', + 'feedback_ids' => implode( ',', $feedback_ids ), + 'post_id' => $post_id, + 'search' => $search, + 'status' => $status, + 'before' => $before, + 'after' => $after, + 'nonce' => $nonce, + ), + admin_url( 'admin-post.php' ) + ); + + return rest_ensure_response( + array( + 'download_url' => $download_url, + 'count' => count( $feedback_ids ), + ) + ); } } 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 fd600f259a864..439c62d5f1d5b 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 @@ -214,6 +214,9 @@ protected function __construct() { if ( is_admin() ) { add_action( 'wp_ajax_create_new_form', array( $this, 'create_new_form' ) ); } + + // Admin-post action for CSV export + add_action( 'admin_post_feedback_export', array( $this, 'admin_post_feedback_export' ) ); add_action( 'admin_menu', array( $this, 'admin_menu' ) ); add_action( 'current_screen', array( $this, 'unread_count' ) ); add_action( 'current_screen', array( $this, 'redirect_edit_feedback_to_jetpack_forms' ) ); @@ -2754,6 +2757,38 @@ function ( $selected ) { return $this->get_export_feedback_data( $feedbacks ); } + /** + * Admin-post handler for CSV export + */ + public function admin_post_feedback_export() { + $feedback_ids_str = sanitize_text_field( wp_unslash( $_GET['feedback_ids'] ?? '' ) ); + $post_id = sanitize_text_field( wp_unslash( $_GET['post_id'] ?? '' ) ); + $nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? '' ) ); + + if ( empty( $feedback_ids_str ) || empty( $nonce ) ) { + wp_die( esc_html__( 'Invalid request parameters.', 'jetpack-forms' ), 400 ); + } + + $feedback_ids = explode( ',', $feedback_ids_str ); + $feedback_ids = array_map( 'intval', $feedback_ids ); + + if ( ! wp_verify_nonce( $nonce, 'feedback_export_' . $feedback_ids_str ) ) { + wp_die( esc_html__( 'Security check failed.', 'jetpack-forms' ), 403 ); + } + + if ( ! current_user_can( 'export' ) ) { + wp_die( esc_html__( 'You do not have permission to export form responses.', 'jetpack-forms' ), 403 ); + } + + $export_data = $this->get_export_feedback_data( $feedback_ids ); + + if ( empty( $export_data ) ) { + wp_die( esc_html__( 'No responses found to export.', 'jetpack-forms' ), 404 ); + } + + $this->download_feedback_as_csv( $export_data, $post_id ); + } + /** * Download exported data as CSV * diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index 05de76d25c5d8..c5b54c7f51521 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -84,7 +84,7 @@ export default function useExportResponses(): ExportHookReturn { return { selected: getSelectedResponsesFromCurrentDataset(), currentQuery: getCurrentQuery() }; }, [] ); - const onExport = useCallback( () => { + const onExport = useCallback( async () => { const exportData: ExportData = { selected: selected.map( id => parseInt( id, 10 ) ), post: currentQuery.parent ? String( currentQuery.parent ) : 'all', @@ -97,12 +97,18 @@ export default function useExportResponses(): ExportHookReturn { exportData.after = currentQuery.after; } - return apiFetch( { + const response = await apiFetch( { path: '/wp/v2/feedback/export', method: 'POST', data: exportData, - parse: false, } ); + + if ( response && response.download_url ) { + // Trigger download by navigating to the URL + window.location.href = response.download_url; + return response; + } + throw new Error( 'Invalid response: missing download URL' ); }, [ currentQuery, selected ] ); useEffect( () => { From 8a3ac4c541f0398966ec9cfaeeeeb52511e88e92 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 24 Sep 2025 09:49:30 -0300 Subject: [PATCH 04/18] simpler --- .../src/contact-form/class-contact-form-endpoint.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 ec36dbc7a3658..b346fe82b24b9 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 @@ -1193,16 +1193,7 @@ public function export_responses( $request ) { return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) ); } - $feedback_ids = array_map( - function ( $post ) { - return $post->ID; - }, - $feedback_posts - ); - - if ( empty( $feedback_ids ) ) { - return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) ); - } + $feedback_ids = wp_list_pluck( $feedback_posts, 'ID' ); $nonce = wp_create_nonce( 'feedback_export_' . implode( ',', $feedback_ids ) ); From b9d4bf10a22837083681454447a0e6d3702b57df Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 24 Sep 2025 09:56:09 -0300 Subject: [PATCH 05/18] add sanitize_calllbacks --- .../class-contact-form-endpoint.php | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 b346fe82b24b9..a2ba9e469aa70 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 @@ -270,24 +270,29 @@ public function register_routes() { 'default' => array(), ), 'post' => array( - 'type' => 'string', - 'default' => 'all', + 'type' => 'string', + 'default' => 'all', + 'sanitize_callback' => 'sanitize_text_field', ), 'search' => array( - 'type' => 'string', - 'default' => '', + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', ), 'status' => array( - 'type' => 'string', - 'default' => 'publish', + 'type' => 'string', + 'default' => 'publish', + 'sanitize_callback' => 'sanitize_text_field', ), 'before' => array( - 'type' => 'string', - 'default' => '', + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', ), 'after' => array( - 'type' => 'string', - 'default' => '', + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', ), ), ) From 9299540e041f3f5842ef5feea4236dc5a5cc4844 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 24 Sep 2025 10:09:00 -0300 Subject: [PATCH 06/18] fix types --- .../forms/src/dashboard/hooks/use-export-responses.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index c5b54c7f51521..65db6db5a577a 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -22,13 +22,18 @@ type ExportData = { after?: string; }; +type ExportResponse = { + download_url: string; + count: number; +}; + type ExportHookReturn = { showExportModal: boolean; openModal: () => void; closeModal: () => void; autoConnectGdrive: boolean; userCanExport: boolean; - onExport: () => Promise< Response >; + onExport: () => Promise< ExportResponse >; selectedResponsesCount: number; currentStatus: string; exportLabel: string; @@ -84,7 +89,7 @@ export default function useExportResponses(): ExportHookReturn { return { selected: getSelectedResponsesFromCurrentDataset(), currentQuery: getCurrentQuery() }; }, [] ); - const onExport = useCallback( async () => { + const onExport = useCallback( async (): Promise< ExportResponse > => { const exportData: ExportData = { selected: selected.map( id => parseInt( id, 10 ) ), post: currentQuery.parent ? String( currentQuery.parent ) : 'all', @@ -97,7 +102,7 @@ export default function useExportResponses(): ExportHookReturn { exportData.after = currentQuery.after; } - const response = await apiFetch( { + const response = await apiFetch< ExportResponse >( { path: '/wp/v2/feedback/export', method: 'POST', data: exportData, From 2d8dae5ce5c0230afe304437fcf395482a83a6c5 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 24 Sep 2025 10:10:27 -0300 Subject: [PATCH 07/18] simpler --- .../forms/src/dashboard/hooks/use-export-responses.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index 65db6db5a577a..1c1857c8e7779 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -91,14 +91,16 @@ export default function useExportResponses(): ExportHookReturn { const onExport = useCallback( async (): Promise< ExportResponse > => { const exportData: ExportData = { - selected: selected.map( id => parseInt( id, 10 ) ), + selected: selected.map( Number ), post: currentQuery.parent ? String( currentQuery.parent ) : 'all', search: currentQuery.search || '', status: currentQuery.status || 'publish', }; - if ( currentQuery.before && currentQuery.after ) { + if ( currentQuery.before ) { exportData.before = currentQuery.before; + } + if ( currentQuery.after ) { exportData.after = currentQuery.after; } From 1149953c640ed758029131312cb3c44367a58d9b Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 24 Sep 2025 10:31:12 -0300 Subject: [PATCH 08/18] simpler --- .../components/export-responses-modal/index.tsx | 7 ++++++- .../src/dashboard/inbox/export-responses/csv.tsx | 16 +--------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx b/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx index 1b263d953421c..b413d0cdf71cf 100644 --- a/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx +++ b/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx @@ -11,9 +11,14 @@ import GoogleDriveExport from '../../inbox/export-responses/google-drive'; import './style.scss'; +type ExportResponse = { + download_url: string; + count: number; +}; + type ExportResponsesModalProps = { onRequestClose: () => void; - onExport: ( action: string, nonceName: string ) => Promise< Response >; + onExport: () => Promise< ExportResponse >; autoConnectGdrive: boolean; }; diff --git a/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx b/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx index a7b5a31ffb6a1..ca1992bf88cf6 100644 --- a/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx +++ b/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx @@ -16,21 +16,7 @@ const CSVExport = ( { onExport } ) => { screen: 'form-responses-inbox', } ); - onExport( 'feedback_export', 'feedback_export_nonce_csv' ).then( async response => { - const blob = await response.blob(); - - const a = document.createElement( 'a' ); - a.href = window.URL.createObjectURL( blob ); - - const contentDispositionHeader = response.headers.get( 'Content-Disposition' ) ?? ''; - a.download = - contentDispositionHeader.split( 'filename=' )[ 1 ] || 'Jetpack Form Responses.csv'; - - document.body.appendChild( a ); - a.click(); - document.body.removeChild( a ); - window.URL.revokeObjectURL( a.href ); - } ); + onExport(); }, [ onExport, tracks ] ); const buttonClasses = clsx( 'button', 'export-button', 'export-csv' ); From ff69dc1e05e71edc70ecca9adc07acd017e9d3a1 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 24 Sep 2025 11:02:08 -0300 Subject: [PATCH 09/18] remove unused params --- .../forms/src/contact-form/class-contact-form-endpoint.php | 5 ----- 1 file changed, 5 deletions(-) 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 963211dd92513..e532c5fa28d25 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 @@ -1204,11 +1204,6 @@ public function export_responses( $request ) { array( 'action' => 'feedback_export', 'feedback_ids' => implode( ',', $feedback_ids ), - 'post_id' => $post_id, - 'search' => $search, - 'status' => $status, - 'before' => $before, - 'after' => $after, 'nonce' => $nonce, ), admin_url( 'admin-post.php' ) From 4a97cab02453050fe2b738a7a5051bdb229c1942 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 25 Sep 2025 15:06:53 -0700 Subject: [PATCH 10/18] Update projects/packages/forms/src/contact-form/class-contact-form-plugin.php Co-authored-by: Manzoor Wani --- .../forms/src/contact-form/class-contact-form-plugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4a400c6725ef2..200b07e7777c4 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 @@ -2769,7 +2769,7 @@ public function admin_post_feedback_export() { } $feedback_ids = explode( ',', $feedback_ids_str ); - $feedback_ids = array_map( 'intval', $feedback_ids ); + $feedback_ids = array_values( array_filter( array_map( 'intval', $feedback_ids ) ) ); if ( ! wp_verify_nonce( $nonce, 'feedback_export_' . $feedback_ids_str ) ) { wp_die( esc_html__( 'Security check failed.', 'jetpack-forms' ), 403 ); From f3e2b8afb09a295cbfc59aa25451436edfb42128 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 25 Sep 2025 15:13:56 -0700 Subject: [PATCH 11/18] Use feedback ids instead --- .../forms/src/contact-form/class-contact-form-endpoint.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 e532c5fa28d25..27f92d1b15908 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 @@ -1161,6 +1161,7 @@ public function export_responses( $request ) { 'post_status' => array( 'publish' ), 'order' => 'ASC', 'suppress_filters' => false, + 'fields' => 'ids', ); if ( $status && $status !== 'publish' ) { @@ -1190,14 +1191,12 @@ public function export_responses( $request ) { $query_args['date_query'] = array( $date_query ); } - $feedback_posts = get_posts( $query_args ); + $feedback_ids = get_posts( $query_args ); - if ( empty( $feedback_posts ) ) { + if ( empty( $feedback_ids ) ) { return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) ); } - $feedback_ids = wp_list_pluck( $feedback_posts, 'ID' ); - $nonce = wp_create_nonce( 'feedback_export_' . implode( ',', $feedback_ids ) ); $download_url = add_query_arg( From d2eb4c2406dc2d18cf26e04203c23c2c39baef4f Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 25 Sep 2025 17:41:33 -0700 Subject: [PATCH 12/18] Show error notice when there are no responses. --- .../dashboard/hooks/use-export-responses.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index 1c1857c8e7779..1d8ded56f4028 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -5,9 +5,10 @@ import jetpackAnalytics from '@automattic/jetpack-analytics'; import { useBreakpointMatch } from '@automattic/jetpack-components'; import apiFetch from '@wordpress/api-fetch'; import { store as coreStore } from '@wordpress/core-data'; -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { useState, useCallback, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ @@ -48,6 +49,7 @@ export default function useExportResponses(): ExportHookReturn { const [ isSm ] = useBreakpointMatch( 'sm' ); const [ showExportModal, setShowExportModal ] = useState( false ); const closeModal = useCallback( () => setShowExportModal( false ), [ setShowExportModal ] ); + const { createErrorNotice } = useDispatch( noticesStore ); const [ autoConnectGdrive, setAutoConnectGdrive ] = useState( false ); const { selectedResponsesCount, currentStatus } = useSelect( select => ( { @@ -104,19 +106,25 @@ export default function useExportResponses(): ExportHookReturn { exportData.after = currentQuery.after; } - const response = await apiFetch< ExportResponse >( { - path: '/wp/v2/feedback/export', - method: 'POST', - data: exportData, - } ); - - if ( response && response.download_url ) { - // Trigger download by navigating to the URL - window.location.href = response.download_url; - return response; + try { + const response = await apiFetch( { + path: '/wp/v2/feedback/export', + method: 'POST', + data: exportData, + } ); + + if ( response && response.download_url ) { + // Trigger download by navigating to the URL + window.location.href = response.download_url; + return response; + } + } catch { + closeModal(); + createErrorNotice( __( 'No responses found to export!', 'jetpack-forms' ), { + type: 'snackbar', + } ); } - throw new Error( 'Invalid response: missing download URL' ); - }, [ currentQuery, selected ] ); + }, [ currentQuery, selected, closeModal, createErrorNotice ] ); useEffect( () => { const url = new URL( window.location.href ); From 123c3e9dbff2ac3d3b31fcf72216bed058a6ddbf Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 25 Sep 2025 21:57:32 -0700 Subject: [PATCH 13/18] Add tests --- .../Contact_Form_Endpoint_Test.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php index 363ff2e7686dc..f13a2825c98ae 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php @@ -462,6 +462,50 @@ public function test_get_forms_config_returns_401_for_unauthorized() { $this->assertEquals( 401, $response->get_status() ); } + /** + * Test GET feedback/config unauthorized access. + */ + public function test_get_forms_export_returns_401_for_unauthorized() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/export' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test GET feedback/config unauthorized access. + */ + public function test_get_forms_export_returns_404_for_authorized_but_empty() { + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/export' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Filter to mock feedback posts returned by get_posts. + */ + public function get_posts_return_feedback( $results, $query ) { + if ( strpos( $query, "wp_posts.post_type = 'feedback'" ) !== false ) { + $results = array( + (object) array( 'ID' => 123 ), + ); + } + return $results; + } + + /** + * Test GET feedback/config unauthorized access. + */ + public function test_get_forms_export_returns_200_for_authorized() { + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/export' ); + $request->set_param( 'selected', array( 123 ) ); + + add_filter( 'wordbless_wpdb_query_results', array( $this, 'get_posts_return_feedback' ), 10, 2 ); + $response = $this->server->dispatch( $request ); + remove_filter( 'wordbless_wpdb_query_results', array( $this, 'get_posts_return_feedback' ), 10 ); + $this->assertEquals( 200, $response->get_status() ); + } + /** * Test resend email functionality */ From 2a669311b5454ec73e4070acf2b47ab1e087ec99 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 25 Sep 2025 23:52:40 -0700 Subject: [PATCH 14/18] Fix linter errors --- .../forms/src/dashboard/hooks/use-export-responses.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index 1d8ded56f4028..d597561dd7645 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -107,17 +107,19 @@ export default function useExportResponses(): ExportHookReturn { } try { - const response = await apiFetch( { + const response = ( await apiFetch( { path: '/wp/v2/feedback/export', method: 'POST', data: exportData, - } ); + } ) ) as ExportResponse; if ( response && response.download_url ) { // Trigger download by navigating to the URL window.location.href = response.download_url; return response; } + + return { download_url: '', count: 0 }; } catch { closeModal(); createErrorNotice( __( 'No responses found to export!', 'jetpack-forms' ), { From 2c414d68361edb764834a692f4a2fd2c861e6072 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 25 Sep 2025 23:52:55 -0700 Subject: [PATCH 15/18] More tests --- .../Contact_Form_Endpoint_Test.php | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php index f13a2825c98ae..70c24615eaca4 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php @@ -463,7 +463,7 @@ public function test_get_forms_config_returns_401_for_unauthorized() { } /** - * Test GET feedback/config unauthorized access. + * Test GET feedback/export unauthorized access. */ public function test_get_forms_export_returns_401_for_unauthorized() { wp_set_current_user( 0 ); @@ -473,7 +473,30 @@ public function test_get_forms_export_returns_401_for_unauthorized() { } /** - * Test GET feedback/config unauthorized access. + * Test GET feedback/export unauthorized access. + */ + public function test_get_forms_export_returns_401_for_user_without_export_permission() { + + $user_id = wp_insert_user( + array( + 'user_login' => 'test_author', + 'user_pass' => '123', + 'role' => 'author', + ) + ); + + wp_set_current_user( $user_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/export' ); + $request->set_param( 'selected', array( 123 ) ); + + add_filter( 'wordbless_wpdb_query_results', array( $this, 'get_posts_return_feedback' ), 10, 2 ); + $response = $this->server->dispatch( $request ); + remove_filter( 'wordbless_wpdb_query_results', array( $this, 'get_posts_return_feedback' ), 10 ); + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * Test GET feedback/export empty authorized access. */ public function test_get_forms_export_returns_404_for_authorized_but_empty() { $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/export' ); @@ -494,7 +517,7 @@ public function get_posts_return_feedback( $results, $query ) { } /** - * Test GET feedback/config unauthorized access. + * Test GET feedback/export authorized access. */ public function test_get_forms_export_returns_200_for_authorized() { $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/export' ); From 8c58151f971279466425d04293b0f84e1e2fe6ab Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Fri, 26 Sep 2025 00:01:31 -0700 Subject: [PATCH 16/18] Pass the $post_id --- .../forms/src/contact-form/class-contact-form-endpoint.php | 1 + 1 file changed, 1 insertion(+) 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 27f92d1b15908..8ad38a30d0f34 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 @@ -1203,6 +1203,7 @@ public function export_responses( $request ) { array( 'action' => 'feedback_export', 'feedback_ids' => implode( ',', $feedback_ids ), + 'post_id' => $post_id, 'nonce' => $nonce, ), admin_url( 'admin-post.php' ) From d4ab5b9e15ff553109ff1a279bea38823eb0f97e Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Fri, 26 Sep 2025 08:26:17 -0700 Subject: [PATCH 17/18] Keep the previous method as it was --- .../class-contact-form-plugin.php | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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 200b07e7777c4..3d199d2fb5929 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 @@ -216,7 +216,7 @@ protected function __construct() { } // Admin-post action for CSV export - add_action( 'admin_post_feedback_export', array( $this, 'admin_post_feedback_export' ) ); + add_action( 'admin_post_feedback_export', array( $this, 'download_feedback_as_csv' ) ); add_action( 'admin_menu', array( $this, 'admin_menu' ) ); add_action( 'current_screen', array( $this, 'unread_count' ) ); add_action( 'current_screen', array( $this, 'redirect_edit_feedback_to_jetpack_forms' ) ); @@ -2759,9 +2759,13 @@ function ( $selected ) { /** * Admin-post handler for CSV export */ - public function admin_post_feedback_export() { + public function download_feedback_as_csv() { + + if ( ! current_user_can( 'export' ) ) { + wp_die( esc_html__( 'You do not have permission to export form responses.', 'jetpack-forms' ), 403 ); + } + $feedback_ids_str = sanitize_text_field( wp_unslash( $_GET['feedback_ids'] ?? '' ) ); - $post_id = sanitize_text_field( wp_unslash( $_GET['post_id'] ?? '' ) ); $nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? '' ) ); if ( empty( $feedback_ids_str ) || empty( $nonce ) ) { @@ -2775,26 +2779,25 @@ public function admin_post_feedback_export() { wp_die( esc_html__( 'Security check failed.', 'jetpack-forms' ), 403 ); } - if ( ! current_user_can( 'export' ) ) { - wp_die( esc_html__( 'You do not have permission to export form responses.', 'jetpack-forms' ), 403 ); - } - $export_data = $this->get_export_feedback_data( $feedback_ids ); if ( empty( $export_data ) ) { wp_die( esc_html__( 'No responses found to export.', 'jetpack-forms' ), 404 ); } - $this->download_feedback_as_csv( $export_data, $post_id ); + $post_id = sanitize_text_field( wp_unslash( $_GET['post_id'] ?? '' ) ); + + $this->download_feedback_as_csv_export( $export_data, $post_id ); } /** - * Download exported data as CSV + * Download exported data as a CSV file. + * This forces the download of the CSV file. * * @param array $data Export data to generate CSV from. * @param string $post_id Optional. Post ID for filename generation. */ - public function download_feedback_as_csv( $data = null, $post_id = '' ) { + public function download_feedback_as_csv_export( $data = null, $post_id = '' ) { if ( empty( $data ) ) { return; } From 1604597650eb49de47818ac97818a4957b3dbdeb Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Thu, 25 Sep 2025 12:43:17 +0300 Subject: [PATCH 18/18] Add export progress indicator to CSV export Introduces an isExporting state to track export progress and prevent UI flickering. The export button is now disabled and shows a busy indicator while exporting, improving user feedback during CSV downloads. props @simison --- .../components/export-responses-modal/index.tsx | 4 +++- .../src/dashboard/hooks/use-export-responses.ts | 15 ++++++++++++++- .../src/dashboard/inbox/export-responses/csv.tsx | 15 +++++++++++++-- .../dashboard/inbox/export-responses/index.tsx | 2 ++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx b/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx index a6bb7aee64f60..5c381151e8333 100644 --- a/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx +++ b/projects/packages/forms/src/dashboard/components/export-responses-modal/index.tsx @@ -18,12 +18,14 @@ type ExportResponsesModalProps = { onRequestClose: () => void; onExport: () => Promise< ExportResponse >; autoConnectGdrive: boolean; + isExporting?: boolean; }; const ExportResponsesModal = ( { onRequestClose, onExport, autoConnectGdrive, + isExporting, }: ExportResponsesModalProps ) => { return ( - + diff --git a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts index d597561dd7645..166bbca126f53 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-export-responses.ts @@ -35,6 +35,7 @@ type ExportHookReturn = { autoConnectGdrive: boolean; userCanExport: boolean; onExport: () => Promise< ExportResponse >; + isExporting: boolean; selectedResponsesCount: number; currentStatus: string; exportLabel: string; @@ -69,6 +70,12 @@ export default function useExportResponses(): ExportHookReturn { statusLabel = __( 'Export trash', 'jetpack-forms' ); } + const delayIsExportingFalse = () => { + setTimeout( () => { + setIsExporting( false ); + }, 1000 ); // Delay it for 1 second to avoid flickering + }; + const exportLabel = selectedResponsesCount > 0 ? `${ statusLabel } (${ selectedResponsesCount })` : statusLabel; @@ -91,7 +98,10 @@ export default function useExportResponses(): ExportHookReturn { return { selected: getSelectedResponsesFromCurrentDataset(), currentQuery: getCurrentQuery() }; }, [] ); + const [ isExporting, setIsExporting ] = useState( false ); + const onExport = useCallback( async (): Promise< ExportResponse > => { + setIsExporting( true ); const exportData: ExportData = { selected: selected.map( Number ), post: currentQuery.parent ? String( currentQuery.parent ) : 'all', @@ -116,15 +126,17 @@ export default function useExportResponses(): ExportHookReturn { if ( response && response.download_url ) { // Trigger download by navigating to the URL window.location.href = response.download_url; + delayIsExportingFalse(); return response; } - + delayIsExportingFalse(); return { download_url: '', count: 0 }; } catch { closeModal(); createErrorNotice( __( 'No responses found to export!', 'jetpack-forms' ), { type: 'snackbar', } ); + setIsExporting( false ); } }, [ currentQuery, selected, closeModal, createErrorNotice ] ); @@ -148,6 +160,7 @@ export default function useExportResponses(): ExportHookReturn { autoConnectGdrive, userCanExport, onExport, + isExporting, selectedResponsesCount, currentStatus, exportLabel, diff --git a/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx b/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx index ca1992bf88cf6..a095d10269d02 100644 --- a/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx +++ b/projects/packages/forms/src/dashboard/inbox/export-responses/csv.tsx @@ -7,7 +7,12 @@ import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; -const CSVExport = ( { onExport } ) => { +type CSVExportProps = { + onExport: () => void; + isExporting?: boolean; +}; + +const CSVExport = ( { onExport, isExporting = false }: CSVExportProps ) => { const { tracks } = useAnalytics(); const downloadCSV = useCallback( () => { @@ -47,7 +52,13 @@ const CSVExport = ( { onExport } ) => { { __( 'Download your form response data as a CSV file.', 'jetpack-forms' ) }
-
diff --git a/projects/packages/forms/src/dashboard/inbox/export-responses/index.tsx b/projects/packages/forms/src/dashboard/inbox/export-responses/index.tsx index 0b7693fb8db71..652557d08c946 100644 --- a/projects/packages/forms/src/dashboard/inbox/export-responses/index.tsx +++ b/projects/packages/forms/src/dashboard/inbox/export-responses/index.tsx @@ -19,6 +19,7 @@ const ExportResponsesButton = () => { userCanExport, onExport, autoConnectGdrive, + isExporting, exportLabel, } = useExportResponses(); @@ -43,6 +44,7 @@ const ExportResponsesButton = () => { onRequestClose={ closeModal } onExport={ onExport } autoConnectGdrive={ autoConnectGdrive } + isExporting={ isExporting } /> ) }