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, '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_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 );
}

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
73 changes: 53 additions & 20 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,38 @@
*/
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
*/
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 All @@ -34,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 => ( {
Expand Down Expand Up @@ -75,25 +91,42 @@ 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 );
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;
}

try {
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 fetch( window.ajaxurl, { method: 'POST', body: data } );
},
[ currentQuery, selected ]
);
return { download_url: '', count: 0 };
} catch {
closeModal();
createErrorNotice( __( 'No responses found to export!', 'jetpack-forms' ), {
type: 'snackbar',
} );
}
}, [ currentQuery, selected, closeModal, createErrorNotice ] );

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
Loading