Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7b88e27
Forms: Allow submitting form previews as test responses
enejb Apr 11, 2026
18f8a7d
Reorder export options before test warning
enejb Apr 11, 2026
b7623ff
Forms: Carry is_test via signed JWT source; a11y + unread-count polish
enejb Apr 13, 2026
ae52a16
Forms: Polish test-response UI per review feedback
enejb Apr 13, 2026
6761ae2
Forms: Show "Form preview" as Source label for test responses
enejb Apr 13, 2026
d243268
Forms: Show "Form preview" source label in legacy inbox list too
enejb Apr 13, 2026
35f8e15
Forms: Restore amber notice for test-submission emails, title only
enejb Apr 13, 2026
82f397f
Forms: Move test-submission banner to very top of email
enejb Apr 13, 2026
296a119
Forms: Reword test-submission email banner heading
enejb Apr 13, 2026
de6e8b5
Forms: Show "Form preview" as email metadata source for test responses
enejb Apr 13, 2026
5058165
Forms: Restore top-left alignment for single response header
enejb Apr 13, 2026
0b2c7c3
Forms: Add bottom spacing to export modal test-response notice
enejb Apr 13, 2026
1f13c51
Forms: Add "Form preview" to Source filter for test responses
enejb Apr 17, 2026
90a9329
Forms: Place "Test" badge inline with respondent name
enejb Apr 17, 2026
90d8a3f
Forms: Restyle test-submission email banner to match WPDS Notice
enejb Apr 17, 2026
5071194
Forms: Swap export modal Notice to @wordpress/ui Notice component
enejb Apr 17, 2026
1c12c79
Forms: Don't fail PHPUnit run on deprecations
enejb Apr 17, 2026
83f2467
Forms: Guard ReflectionProperty::setAccessible() by PHP version
enejb Apr 17, 2026
07a89ea
Forms: Drop changelog entry for the PHPUnit deprecation guard
enejb Apr 18, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Form preview now lets you submit the form to test the full submission flow end to end. Responses created from preview are stored as test responses, clearly flagged in the notification email, and excluded from the default CSV export.
48 changes: 40 additions & 8 deletions projects/packages/forms/routes/responses/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ type FeedbackFilters = {

const EMPTY_ARRAY = [];

// Sentinel value used in the Source filter to represent form-preview (test) responses.
// Source IDs are numeric post IDs, so this non-numeric value is safe from collision.
const FORM_PREVIEW_SOURCE_VALUE = 'form_preview';

const defaultLayouts = {
table: {},
list: {},
Expand All @@ -71,6 +75,7 @@ type QueryParams = {
orderby?: string;
order?: string;
is_unread?: boolean;
is_test?: boolean;
parent?: string;
source?: string;
before?: string;
Expand Down Expand Up @@ -309,7 +314,11 @@ function StageInner() {
queryArgs.is_unread = filter.value === 'unread';
}
if ( ! isSingleFormView && filter.field === 'source' ) {
queryArgs.source = filter.value;
if ( filter.value === FORM_PREVIEW_SOURCE_VALUE ) {
queryArgs.is_test = true;
} else {
queryArgs.source = filter.value;
}
}
if ( filter.field === 'date' ) {
const [ year, month ] = filter.value.split( '/' ).map( Number );
Expand Down Expand Up @@ -432,9 +441,16 @@ function StageInner() {
/>
{ styleUnreadValue(
<Stack direction="column" gap="2xs">
<Text ellipsizeMode="tail" limit={ 50 } truncate>
{ displayName }
</Text>
<Stack direction="row" align="center" gap="xs">
<Text ellipsizeMode="tail" limit={ 50 } truncate>
{ displayName }
</Text>
{ item.is_test && (
<Badge intent="none" aria-label={ __( 'Test response', 'jetpack-forms' ) }>
{ __( 'Test', 'jetpack-forms' ) }
</Badge>
) }
</Stack>
{ showEmail && (
<Text variant="muted" size={ 12 } ellipsizeMode="tail" limit={ 50 } truncate>
{ item.author_email }
Expand Down Expand Up @@ -479,6 +495,18 @@ function StageInner() {
id: 'source',
label: __( 'Source', 'jetpack-forms' ),
render: ( { item } ) => {
// Test responses point at the regenerated preview URL instead of
// the hosting page, and always surface as "Form preview".
if ( item.is_test ) {
const previewLabel = __( 'Form preview', 'jetpack-forms' );
if ( item.preview_url ) {
return styleUnreadValue(
<ExternalLink href={ item.preview_url }>{ previewLabel }</ExternalLink>,
item.is_unread
);
}
return styleUnreadValue( previewLabel, item.is_unread );
}
const source =
item.entry_title ||
getUrlPath( item.entry_permalink ) ||
Expand All @@ -491,15 +519,19 @@ function StageInner() {
}
return styleUnreadValue( source, item.is_unread );
},
elements: ( ( filterOptions as unknown as FeedbackFilters )?.source || [] ).map(
source => ( {
elements: [
{
value: FORM_PREVIEW_SOURCE_VALUE,
label: __( 'Form preview', 'jetpack-forms' ),
},
...( ( filterOptions as unknown as FeedbackFilters )?.source || [] ).map( source => ( {
value: source.id.toString(),
label:
decodeEntities( source.title ) ||
getUrlPath( source.url ) ||
__( '(no title)', 'jetpack-forms' ),
} )
),
} ) ),
],
filterBy: isSingleFormView ? false : { operators: [ 'is' ] as Operator[] },
enableSorting: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@ public function register_routes() {
'sanitize_callback' => 'rest_sanitize_boolean',
'validate_callback' => 'rest_validate_request_arg',
),
'is_test' => array(
'description' => 'Limit results to test responses or exclude them.',
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'validate_callback' => 'rest_validate_request_arg',
),
'source' => array(
'description' => 'Limit results to feedback submitted from a specific source post ID.',
'type' => 'integer',
Expand Down Expand Up @@ -477,6 +483,7 @@ public function get_status_counts( $request ) {
$before = $request->get_param( 'before' );
$after = $request->get_param( 'after' );
$is_unread = $request->get_param( 'is_unread' );
$is_test = $request->get_param( 'is_test' );

$join_clause = '';
$where_conditions = array( $wpdb->prepare( "{$wpdb->posts}.post_type = %s", 'feedback' ) );
Expand Down Expand Up @@ -509,6 +516,12 @@ public function get_status_counts( $request ) {
$where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.comment_status = %s", $comment_status );
}

if ( null !== $is_test ) {
$is_test_meta_key = esc_sql( Feedback::IS_TEST_META_KEY );
$join_clause .= " LEFT JOIN {$wpdb->postmeta} AS is_test_meta ON ({$wpdb->posts}.ID = is_test_meta.post_id AND is_test_meta.meta_key = '{$is_test_meta_key}')";
$where_conditions[] = $is_test ? "is_test_meta.meta_value = '1'" : 'is_test_meta.meta_id IS NULL';
}

$where_clause = implode( ' AND ', $where_conditions );

// Execute single query with CASE statements for all status counts.
Expand Down Expand Up @@ -807,6 +820,26 @@ public function get_item_schema() {
'readonly' => true,
);

$schema['properties']['is_test'] = array(
'description' => __( 'Whether the form response was submitted from a form preview (test response).', 'jetpack-forms' ),
'type' => 'boolean',
'context' => array( 'view', 'edit', 'embed' ),
'arg_options' => array(
'sanitize_callback' => 'rest_sanitize_boolean',
),
'readonly' => true,
);

$schema['properties']['preview_url'] = array(
'description' => __( 'URL to the form preview that produced this response, when the response is a test submission.', 'jetpack-forms' ),
'type' => array( 'string', 'null' ),
'context' => array( 'view', 'edit', 'embed' ),
'arg_options' => array(
'sanitize_callback' => 'esc_url_raw',
),
'readonly' => true,
);

$this->schema = $schema;

return $this->add_additional_fields_schema( $this->schema );
Expand Down Expand Up @@ -988,6 +1021,21 @@ public function prepare_item_for_response( $item, $request ) {
$data['is_unread'] = $feedback_response->is_unread();
}

if ( rest_is_field_included( 'is_test', $fields ) ) {
$data['is_test'] = $feedback_response->is_test();
}

if ( rest_is_field_included( 'preview_url', $fields ) ) {
$preview_url = null;
if ( $feedback_response->is_test() ) {
$form_id = $feedback_response->get_form_id();
if ( $form_id ) {
$preview_url = Form_Preview::generate_preview_url( (int) $form_id );
}
}
$data['preview_url'] = $preview_url;
}

$response->set_data( $data );

return rest_ensure_response( $response );
Expand Down Expand Up @@ -1102,6 +1150,25 @@ protected function prepare_items_query( $args = array(), $request = null ) {
add_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10, 2 );
}

// Filter by test/non-test responses via the _feedback_is_test meta.
$is_test = $request->get_param( 'is_test' );
if ( null !== $is_test ) {
$meta_query = isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : array();
if ( $is_test ) {
$meta_query[] = array(
'key' => Feedback::IS_TEST_META_KEY,
'value' => '1',
'compare' => '=',
);
} else {
$meta_query[] = array(
'key' => Feedback::IS_TEST_META_KEY,
'compare' => 'NOT EXISTS',
);
}
$args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}

return $args;
}

Expand Down Expand Up @@ -1171,6 +1238,12 @@ public function get_collection_params() {
'sanitize_callback' => 'rest_sanitize_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
$query_params['is_test'] = array(
'description' => __( 'Limit result set to test responses (from form preview) or exclude them.', 'jetpack-forms' ),
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
$query_params['invalid_ids'] = array(
'description' => __( 'List of item IDs to include in results regardless of filters.', 'jetpack-forms' ),
'type' => 'array',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1607,11 +1607,6 @@ public static function recalculate_unread_count() {
* Conditionally attached to `template_redirect`
*/
public function process_form_submission() {
// Block submissions in preview mode.
if ( Form_Preview::is_preview_mode() ) {
return;
}

// Add a filter to replace tokens in the subject field with sanitized field values.
add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );

Expand Down Expand Up @@ -1721,6 +1716,14 @@ public function process_form_submission() {
if ( Jetpack_Forms::is_webhooks_enabled() && ! empty( $form->attributes['webhooks'] ) ) {
Form_Webhooks::init();
}

// The decoded JWT carries a serialized Feedback_Source; when the
// form was rendered in preview mode that source has is_test=true.
// Flag the submission accordingly so the response is stored as a
// test response. JWTs issued before this feature shipped simply
// omit the flag and behave as regular submissions.
$form->set_is_preview_submission( $form->get_source()->is_test() );

// Process the form
return $form->process_submission();
}
Expand Down Expand Up @@ -2906,11 +2909,14 @@ public function personal_data_search_filter( $search ) {
/**
* Returns an array of feedback data for export.
*
* @param array $feedback_ids Array of feedback IDs to fetch the data for.
* @param array $feedback_ids Array of feedback IDs to fetch the data for.
* @param bool $include_test_responses Whether to include feedback that was submitted
* from form preview. Defaults to false, meaning
* preview/test responses are excluded from the export.
*
* @return array
*/
public function get_export_feedback_data( $feedback_ids ) {
public function get_export_feedback_data( $feedback_ids, $include_test_responses = false ) {
$feedback_data = array();
$all_field_names = array();

Expand All @@ -2921,6 +2927,11 @@ public function get_export_feedback_data( $feedback_ids ) {
continue; // Skip if the feedback is not an instance of Feedback.
}

// Skip test responses from form preview unless explicitly requested.
if ( ! $include_test_responses && $response->is_test() ) {
continue;
}

// Get fields with automatic duplicate handling (label-value shape includes counts)
$compiled_fields = $response->get_compiled_fields( 'csv', 'label-value' );

Expand Down Expand Up @@ -3078,7 +3089,8 @@ public function get_feedback_entries_from_post() {
}
}

if ( ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] ) ) {
$has_explicit_selection = ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] );
if ( $has_explicit_selection ) {
$args['include'] = array_filter(
array_map(
function ( $selected ) {
Expand All @@ -3091,7 +3103,11 @@ function ( $selected ) {

$feedbacks = get_posts( $args );

return $this->get_export_feedback_data( $feedbacks );
// Test responses from form preview are excluded from bulk exports by
// default. When the user has explicitly picked specific rows (via the
// dashboard selection UI), we trust their selection and include any
// test responses that landed in it.
return $this->get_export_feedback_data( $feedbacks, $has_explicit_selection );
}

/**
Expand Down
Loading
Loading