Skip to content

Commit e9b26ba

Browse files
committed
Forms: Add REST API endpoint for exporting responses
This replaces the legacy AJAX-based export with a REST API endpoint. Changes: - Add new `/wp/v2/feedback/export` REST endpoint - Remove legacy AJAX handler `wp_ajax_feedback_export` - Remove deprecated `download_feedback_as_csv()` method - Update dashboard export hook to use REST API with @wordpress/api-fetch - Properly handle permission checks with WP_Error responses - Support filtering by selected items, search, status, date ranges, and source
1 parent 3e80bb4 commit e9b26ba

File tree

3 files changed

+213
-101
lines changed

3 files changed

+213
-101
lines changed

projects/packages/forms/src/contact-form/class-contact-form-endpoint.php

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,43 @@ public function register_routes() {
255255
'callback' => array( $this, 'get_forms_config' ),
256256
)
257257
);
258+
259+
register_rest_route(
260+
$this->namespace,
261+
$this->rest_base . '/export',
262+
array(
263+
'methods' => \WP_REST_Server::CREATABLE,
264+
'permission_callback' => array( $this, 'export_permissions_check' ),
265+
'callback' => array( $this, 'export_responses' ),
266+
'args' => array(
267+
'selected' => array(
268+
'type' => 'array',
269+
'items' => array( 'type' => 'integer' ),
270+
'default' => array(),
271+
),
272+
'post' => array(
273+
'type' => 'string',
274+
'default' => 'all',
275+
),
276+
'search' => array(
277+
'type' => 'string',
278+
'default' => '',
279+
),
280+
'status' => array(
281+
'type' => 'string',
282+
'default' => 'publish,draft',
283+
),
284+
'before' => array(
285+
'type' => 'string',
286+
'default' => '',
287+
),
288+
'after' => array(
289+
'type' => 'string',
290+
'default' => '',
291+
),
292+
),
293+
)
294+
);
258295
}
259296

260297
/**
@@ -1070,4 +1107,158 @@ public function get_forms_config( WP_REST_Request $request ) { // phpcs:ignore V
10701107

10711108
return rest_ensure_response( $config );
10721109
}
1110+
1111+
/**
1112+
* Checks if a given request has permissions to export responses.
1113+
*
1114+
* @param WP_REST_Request $request The request object.
1115+
* @return bool|WP_Error True if the request can export, error object otherwise.
1116+
*/
1117+
public function export_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1118+
if ( is_super_admin() ) {
1119+
return true;
1120+
}
1121+
1122+
if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) {
1123+
return new WP_Error(
1124+
'rest_user_not_member',
1125+
__( 'Sorry, you are not a member of this site.', 'jetpack-forms' ),
1126+
array( 'status' => rest_authorization_required_code() )
1127+
);
1128+
}
1129+
1130+
if ( ! current_user_can( 'export' ) ) {
1131+
return new WP_Error(
1132+
'rest_user_cannot_export',
1133+
__( 'Sorry, you are not allowed to export form responses on this site.', 'jetpack-forms' ),
1134+
array( 'status' => rest_authorization_required_code() )
1135+
);
1136+
}
1137+
1138+
return true;
1139+
}
1140+
1141+
/**
1142+
* Export form responses to CSV.
1143+
*
1144+
* @param WP_REST_Request $request The request object.
1145+
* @return WP_REST_Response|WP_Error The response containing CSV data or error.
1146+
*/
1147+
public function export_responses( $request ) {
1148+
$selected = $request->get_param( 'selected' );
1149+
$post_id = $request->get_param( 'post' );
1150+
$search = $request->get_param( 'search' );
1151+
$status = $request->get_param( 'status' );
1152+
$before = $request->get_param( 'before' );
1153+
$after = $request->get_param( 'after' );
1154+
1155+
$query_args = array(
1156+
'post_type' => 'feedback',
1157+
'posts_per_page' => -1,
1158+
'post_status' => array( 'publish', 'draft' ),
1159+
'order' => 'ASC',
1160+
'suppress_filters' => false,
1161+
);
1162+
1163+
if ( $status && $status !== 'publish,draft' ) {
1164+
$query_args['post_status'] = explode( ',', $status );
1165+
}
1166+
1167+
if ( $post_id && $post_id !== 'all' ) {
1168+
$query_args['post_parent'] = intval( $post_id );
1169+
}
1170+
1171+
if ( $search ) {
1172+
$query_args['s'] = $search;
1173+
}
1174+
1175+
if ( ! empty( $selected ) ) {
1176+
$query_args['post__in'] = array_map( 'intval', $selected );
1177+
}
1178+
1179+
if ( $before || $after ) {
1180+
$date_query = array();
1181+
if ( $before ) {
1182+
$date_query['before'] = $before;
1183+
}
1184+
if ( $after ) {
1185+
$date_query['after'] = $after;
1186+
}
1187+
$query_args['date_query'] = array( $date_query );
1188+
}
1189+
1190+
$feedback_posts = get_posts( $query_args );
1191+
1192+
if ( empty( $feedback_posts ) ) {
1193+
return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) );
1194+
}
1195+
1196+
$data = array();
1197+
$all_fields = array();
1198+
1199+
foreach ( $feedback_posts as $post ) {
1200+
$post_data = array(
1201+
'ID' => $post->ID,
1202+
'Date' => get_the_date( 'Y-m-d H:i:s', $post ),
1203+
'Author Name' => get_post_meta( $post->ID, '_feedback_author', true ),
1204+
'Author Email' => get_post_meta( $post->ID, '_feedback_author_email', true ),
1205+
'Author URL' => get_post_meta( $post->ID, '_feedback_author_url', true ),
1206+
'IP Address' => get_post_meta( $post->ID, '_feedback_ip', true ),
1207+
'Contact Form' => get_the_title( $post->post_parent ),
1208+
);
1209+
1210+
$all_meta = get_post_meta( $post->ID );
1211+
foreach ( $all_meta as $key => $value ) {
1212+
if ( strpos( $key, '_' ) === 0 ) {
1213+
continue;
1214+
}
1215+
$post_data[ $key ] = is_array( $value ) && count( $value ) === 1 ? $value[0] : ( is_array( $value ) ? implode( ', ', $value ) : $value );
1216+
}
1217+
1218+
$all_fields = array_merge( $all_fields, array_keys( $post_data ) );
1219+
$data[] = $post_data;
1220+
}
1221+
1222+
$all_fields = array_unique( $all_fields );
1223+
$csv_data = array( $all_fields );
1224+
1225+
foreach ( $data as $post_data ) {
1226+
$row = array();
1227+
foreach ( $all_fields as $field ) {
1228+
$row[] = isset( $post_data[ $field ] ) ? $post_data[ $field ] : '';
1229+
}
1230+
$csv_data[] = $row;
1231+
}
1232+
1233+
$csv_content = '';
1234+
foreach ( $csv_data as $row ) {
1235+
$csv_content .= '"' . implode( '","', array_map( 'esc_attr', $row ) ) . '"' . "\n";
1236+
}
1237+
1238+
if ( ! empty( $post_id ) && $post_id !== 'all' ) {
1239+
$filename = sprintf(
1240+
'%s - %s.csv',
1241+
\Automattic\Jetpack\Forms\ContactForm\Util::get_export_filename( get_the_title( (int) $post_id ) ),
1242+
gmdate( 'Y-m-d H:i' )
1243+
);
1244+
} else {
1245+
$filename = sprintf(
1246+
'%s - %s.csv',
1247+
\Automattic\Jetpack\Forms\ContactForm\Util::get_export_filename(),
1248+
gmdate( 'Y-m-d H:i' )
1249+
);
1250+
}
1251+
1252+
return new WP_REST_Response(
1253+
$csv_content,
1254+
200,
1255+
array(
1256+
'Content-Type' => 'text/csv; charset=utf-8',
1257+
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
1258+
'Cache-Control' => 'no-cache, no-store, must-revalidate',
1259+
'Pragma' => 'no-cache',
1260+
'Expires' => '0',
1261+
)
1262+
);
1263+
}
10731264
}

projects/packages/forms/src/contact-form/class-contact-form-plugin.php

Lines changed: 0 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,7 @@ protected function __construct() {
211211
add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_personal_data_exporter' ) );
212212
add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
213213

214-
// Export to CSV feature
215214
if ( is_admin() ) {
216-
add_action( 'wp_ajax_feedback_export', array( $this, 'download_feedback_as_csv' ) );
217215
add_action( 'wp_ajax_create_new_form', array( $this, 'create_new_form' ) );
218216
}
219217
add_action( 'admin_menu', array( $this, 'admin_menu' ) );
@@ -2756,84 +2754,6 @@ function ( $selected ) {
27562754
return $this->get_export_feedback_data( $feedbacks );
27572755
}
27582756

2759-
/**
2760-
* Download exported data as CSV
2761-
*/
2762-
public function download_feedback_as_csv() {
2763-
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verification is done on get_feedback_entries_from_post function
2764-
$post_data = wp_unslash( $_POST );
2765-
$data = $this->get_feedback_entries_from_post();
2766-
2767-
if ( empty( $data ) ) {
2768-
return;
2769-
}
2770-
2771-
// Check if we want to download all the feedbacks or just a certain contact form
2772-
if ( ! empty( $post_data['post'] ) && $post_data['post'] !== 'all' ) {
2773-
$filename = sprintf(
2774-
'%s - %s.csv',
2775-
Util::get_export_filename( get_the_title( (int) $post_data['post'] ) ),
2776-
gmdate( 'Y-m-d H:i' )
2777-
);
2778-
} else {
2779-
$filename = sprintf(
2780-
'%s - %s.csv',
2781-
Util::get_export_filename(),
2782-
gmdate( 'Y-m-d H:i' )
2783-
);
2784-
}
2785-
2786-
/**
2787-
* Extract field names from `$data` for later use.
2788-
*/
2789-
$fields = array_keys( $data );
2790-
2791-
/**
2792-
* Count how many rows will be exported.
2793-
*/
2794-
$row_count = count( reset( $data ) );
2795-
2796-
// Forces the download of the CSV instead of echoing
2797-
header( 'Content-Disposition: attachment; filename=' . $filename );
2798-
header( 'Pragma: no-cache' );
2799-
header( 'Expires: 0' );
2800-
header( 'Content-Type: text/csv; charset=utf-8' );
2801-
2802-
$output = fopen( 'php://output', 'w' );
2803-
2804-
/**
2805-
* Print CSV headers
2806-
*/
2807-
// @todo When we drop support for PHP <7.4, consider passing empty-string for `$escape` here for better spec compatibility.
2808-
fputcsv( $output, $fields, ',', '"', '\\' );
2809-
2810-
/**
2811-
* Print rows to the output.
2812-
*/
2813-
for ( $i = 0; $i < $row_count; $i++ ) {
2814-
2815-
$current_row = array();
2816-
2817-
/**
2818-
* Put all the fields in `$current_row` array.
2819-
*/
2820-
foreach ( $fields as $single_field_name ) {
2821-
$current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] );
2822-
}
2823-
2824-
/**
2825-
* Output the complete CSV row
2826-
*/
2827-
// @todo When we drop support for PHP <7.4, consider passing empty-string for `$escape` here for better spec compatibility.
2828-
fputcsv( $output, $current_row, ',', '"', '\\' );
2829-
}
2830-
2831-
fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
2832-
2833-
$this->record_tracks_event( 'forms_export_responses', array( 'format' => 'csv' ) );
2834-
exit( 0 );
2835-
}
2836-
28372757
/**
28382758
* Create a new page with a Form block
28392759
*/

projects/packages/forms/src/dashboard/hooks/use-export-responses.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
*/
44
import jetpackAnalytics from '@automattic/jetpack-analytics';
55
import { useBreakpointMatch } from '@automattic/jetpack-components';
6+
import apiFetch from '@wordpress/api-fetch';
67
import { store as coreStore } from '@wordpress/core-data';
78
import { useSelect } from '@wordpress/data';
89
import { useState, useCallback, useEffect } from '@wordpress/element';
910
import { __ } from '@wordpress/i18n';
1011
/**
1112
* Internal dependencies
1213
*/
13-
import { config } from '..';
1414
import { store as dashboardStore } from '../store';
1515

1616
type ExportHookReturn = {
@@ -19,7 +19,7 @@ type ExportHookReturn = {
1919
closeModal: () => void;
2020
autoConnectGdrive: boolean;
2121
userCanExport: boolean;
22-
onExport: ( action: string, nonceName: string ) => Promise< Response >;
22+
onExport: () => Promise< Response >;
2323
selectedResponsesCount: number;
2424
currentStatus: string;
2525
exportLabel: string;
@@ -75,25 +75,26 @@ export default function useExportResponses(): ExportHookReturn {
7575
return { selected: getSelectedResponsesFromCurrentDataset(), currentQuery: getCurrentQuery() };
7676
}, [] );
7777

78-
const onExport = useCallback(
79-
( action: string, nonceName: string ) => {
80-
const data = new FormData();
81-
data.append( 'action', action );
82-
data.append( nonceName, config( 'exportNonce' ) );
83-
selected.forEach( ( id: string ) => data.append( 'selected[]', id ) );
84-
data.append( 'post', currentQuery.parent || 'all' );
85-
data.append( 'search', currentQuery.search || '' );
86-
data.append( 'status', currentQuery.status );
87-
88-
if ( currentQuery.before && currentQuery.after ) {
89-
data.append( 'before', currentQuery.before );
90-
data.append( 'after', currentQuery.after );
91-
}
92-
93-
return fetch( window.ajaxurl, { method: 'POST', body: data } );
94-
},
95-
[ currentQuery, selected ]
96-
);
78+
const onExport = useCallback( () => {
79+
const exportData = {
80+
selected: selected.map( id => parseInt( id, 10 ) ),
81+
post: currentQuery.parent ? String( currentQuery.parent ) : 'all',
82+
search: currentQuery.search || '',
83+
status: currentQuery.status || 'publish,draft',
84+
};
85+
86+
if ( currentQuery.before && currentQuery.after ) {
87+
exportData.before = currentQuery.before;
88+
exportData.after = currentQuery.after;
89+
}
90+
91+
return apiFetch( {
92+
path: '/wp/v2/feedback/export',
93+
method: 'POST',
94+
data: exportData,
95+
parse: false,
96+
} );
97+
}, [ currentQuery, selected ] );
9798

9899
useEffect( () => {
99100
const url = new URL( window.location.href );

0 commit comments

Comments
 (0)