-
Notifications
You must be signed in to change notification settings - Fork 839
Forms: Add REST API endpoint for exporting responses #45275
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from 9 commits
06c5d76
f28cd8a
258fed2
8a3ac4c
b9d4bf1
9299540
2d8dae5
1149953
e610b0c
ff69dc1
4a97cab
f3e2b8a
d2eb4c2
123c3e9
2a66931
2c414d6
8c58151
d4ab5b9
1604597
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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', | ||
| ), | ||
| ), | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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' ); | ||
|
||
|
|
||
| $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, | ||
lezama marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| '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 |
|---|---|---|
|
|
@@ -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' ) ); | ||
|
|
@@ -2756,22 +2757,53 @@ function ( $selected ) { | |
| } | ||
|
|
||
| /** | ||
| * Download exported data as CSV | ||
| * Admin-post handler for CSV export | ||
| */ | ||
| public function download_feedback_as_csv() { | ||
lezama marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // 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'] ?? '' ) ); | ||
|
||
| $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 ); | ||
enejb marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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' ); | ||
|
||
| }, [ currentQuery, selected ] ); | ||
|
|
||
| useEffect( () => { | ||
| const url = new URL( window.location.href ); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.