Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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,115 @@ 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 = wp_list_pluck( $feedback_posts, 'ID' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add 'fields' => 'ids' to $query_args above to get rid of this step and make it a bit faster.


$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 ),
)
);
}
}
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, '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' ) );
Expand Down Expand Up @@ -2756,22 +2757,53 @@ 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();
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'] ?? '' ) );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't pass the post_id when generating the URL from the REST endpoint. Do we?

$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
*
* @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 = '' ) {
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,9 +9,14 @@ 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;
};

Expand Down
65 changes: 44 additions & 21 deletions projects/packages/forms/src/dashboard/hooks/use-export-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,37 @@
*/
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';
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 ExportResponse = {
download_url: string;
count: number;
};

type ExportHookReturn = {
showExportModal: boolean;
openModal: () => void;
closeModal: () => void;
autoConnectGdrive: boolean;
userCanExport: boolean;
onExport: ( action: string, nonceName: string ) => Promise< Response >;
onExport: () => Promise< ExportResponse >;
selectedResponsesCount: number;
currentStatus: string;
exportLabel: string;
Expand Down Expand Up @@ -75,25 +89,34 @@ 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( async (): Promise< ExportResponse > => {
const exportData: ExportData = {
selected: selected.map( Number ),
post: currentQuery.parent ? String( currentQuery.parent ) : 'all',
search: currentQuery.search || '',
status: currentQuery.status || 'publish',
};

if ( currentQuery.before ) {
exportData.before = currentQuery.before;
}
if ( currentQuery.after ) {
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;
}
throw new Error( 'Invalid response: missing download URL' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to translate this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say not yet since it would only show up in the dev console for now.

}, [ currentQuery, selected ] );

useEffect( () => {
const url = new URL( window.location.href );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down
Loading