Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions projects/packages/forms/changelog/rest-export-endpoint
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add REST API endpoint for exporting form responses, replacing legacy AJAX implementation
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,48 @@ 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',
'sanitize_callback' => 'sanitize_text_field',
),
'search' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'status' => array(
'type' => 'string',
'default' => 'publish',
'sanitize_callback' => 'sanitize_text_field',
),
'before' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'after' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}

/**
Expand Down Expand Up @@ -1068,4 +1110,110 @@ 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,
'fields' => 'ids',
);

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_ids = get_posts( $query_args );

if ( empty( $feedback_ids ) ) {
return new WP_Error( 'no_responses', __( 'No responses found', 'jetpack-forms' ), array( 'status' => 404 ) );
}

$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,
'nonce' => $nonce,
),
admin_url( 'admin-post.php' )
);

return rest_ensure_response(
array(
'download_url' => $download_url,
'count' => count( $feedback_ids ),
)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,12 @@ 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' ) );
}

// Admin-post action for CSV 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' ) );
Expand Down Expand Up @@ -2756,22 +2757,56 @@ function ( $selected ) {
}

/**
* Download exported data as CSV
* Admin-post handler for CSV export
*/
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();

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'] ?? '' ) );
$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_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 );
}

$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 );
}

$post_id = sanitize_text_field( wp_unslash( $_GET['post_id'] ?? '' ) );

$this->download_feedback_as_csv_export( $export_data, $post_id );
}

/**
* 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_export( $data = null, $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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,23 @@ import { __ } from '@wordpress/i18n';
import CSVExport from '../../inbox/export-responses/csv';
import GoogleDriveExport from '../../inbox/export-responses/google-drive';

type ExportResponse = {
download_url: string;
count: number;
};

type ExportResponsesModalProps = {
onRequestClose: () => void;
onExport: ( action: string, nonceName: string ) => Promise< Response >;
onExport: () => Promise< ExportResponse >;
autoConnectGdrive: boolean;
isExporting?: boolean;
};

const ExportResponsesModal = ( {
onRequestClose,
onExport,
autoConnectGdrive,
isExporting,
}: ExportResponsesModalProps ) => {
return (
<Modal
Expand All @@ -27,7 +34,7 @@ const ExportResponsesModal = ( {
size="large"
>
<VStack spacing={ 8 }>
<CSVExport onExport={ onExport } />
<CSVExport onExport={ onExport } isExporting={ Boolean( isExporting ) } />
<GoogleDriveExport onExport={ onExport } autoConnect={ autoConnectGdrive } />
</VStack>
</Modal>
Expand Down
Loading
Loading