Skip to content

Commit 54cb1b0

Browse files
enejbclaude
andauthored
Forms: Allow submitting form previews as test responses (#48057)
* Forms: Allow submitting form previews as test responses Form preview now lets you submit the form to test the full submission flow end to end. Responses created from preview are stored normally in the inbox but flagged as test responses: clearly badged in the dashboard list and detail panel, prefixed with [TEST] in the notification email with a banner, excluded from the default CSV export, and excluded from exports unless explicitly selected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Reorder export options before test warning Move CSVExport and GoogleDriveExport components above the selected-test warning and remove the duplicate render. The Google Drive export remains conditionally rendered when enabled and preserves the autoConnect prop. This reordering makes export controls visible before the warning about exporting test responses. * Forms: Carry is_test via signed JWT source; a11y + unread-count polish Follow-ups from review feedback on #48057: - Replace the hidden-field + nonce injection in form preview rendering with a flag baked into the signed JWT. Feedback_Source::get_current() now reads Form_Preview::is_preview_mode() at render time and records is_test=true on the source, which travels tamper-proof inside the JWT to submission. JWTs issued before this change omit the flag and continue to behave as regular submissions. - Remove the now-unused inject_preview_submission_fields() helper, verify_preview_submission() helper, and the three PREVIEW_SUBMIT_* constants from Form_Preview. - Drop the test-feedback special case in Feedback::save(); test responses are now created as STATUS_UNREAD like any other response so they contribute to the unread count. - Add aria-label="Test response" to the inline Test badge in the dashboard From column so screen readers get a complete label. - Add two PHPUnit round-trip tests covering the JWT backward-compat contract: a JWT issued in preview mode marks the decoded form as a test submission; a JWT issued outside preview mode (or from a cached HTML fragment predating this feature) decodes to a regular submission. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Forms: Polish test-response UI per review feedback - List view: move Test badge to end of the From row, switch to the neutral white badge, and show the form title (linked to preview) in the Source column instead of the Form Preview badge. - Detail panel: drop the standalone banner row, render the Test badge inline (right-aligned) next to name/email, and link the form title in the Source row. - Export modal: drop the bottom info notice, move the top notice above the content card, and tone it down from warning to info. - Email: replace the amber test-submission banner with a simple heading line above the feedback body. * Forms: Show "Form preview" as Source label for test responses Revert to surfacing "Form preview" (sentence case) as the Source label for test feedback in both the list Source column and the detail panel, linked to the regenerated preview URL when available. * Forms: Show "Form preview" source label in legacy inbox list too Apply the same "Form preview" Source label + preview-URL link to the legacy DataViews inbox list so test responses surface consistently whether or not CFM is enabled. * Forms: Restore amber notice for test-submission emails, title only Bring back the amber notice block at the top of test-submission emails, but drop the secondary explanatory sentence — just the "Test submission from form preview" heading now. * Forms: Move test-submission banner to very top of email Pass the test-submission banner through wrap_message_in_html_tags as a new optional argument and render it above the white card in the email template, so the rest of the body still reads like a normal submission email. * Forms: Reword test-submission email banner heading Swap the banner copy from "Test submission from form preview" to "Test response via form preview" to match the terminology used in the dashboard. * Forms: Show "Form preview" as email metadata source for test responses Override the Source row in the notification email metadata table so test responses read as "Form preview" (no link), matching the dashboard treatment, instead of the hosting page URL. * Forms: Restore top-left alignment for single response header Keep the original top-left alignment for the gravatar/name block in the single response header. The Test badge keeps itself vertically centered with alignSelf so test rows still look balanced. * Forms: Add bottom spacing to export modal test-response notice Wrap the test-response notice in a spacer div so it doesn't butt up against the CSV export card below. * Forms: Add "Form preview" to Source filter for test responses Replace the planned separate "Response type" filter with a virtual "Form preview" option injected at the top of the existing Source filter. When selected, the source value "form_preview" is translated to is_test=true on the query. Keeps filtering UX to a single pill and avoids the extra filter chrome. Also exposes is_test on the feedback REST collection (GET /feedback) and stamps test submissions with the _feedback_is_test post meta on insert, so the filter can scope at the database level without unpacking the serialized source. * Forms: Place "Test" badge inline with respondent name Move the "Test" badge out of a marginLeft:auto span and into a row next to the respondent name in both the response list and the single-response inspector. The auto-margin pushed the badge to the cell's far edge, which on narrow widths could overflow into the adjacent Date column. * Forms: Restyle test-submission email banner to match WPDS Notice Drop the 4px left-border accent in favor of the @wordpress/ui Notice warning palette: 1px amber border, 8px radius, warm amber fill (#fff7e0 / #d0b381), and 13px/20px body copy in #2e1900. Punctuate the body copy ("Test response via form preview.") and add mobile-only side margin so the banner aligns with the rest of the email content on narrow viewports. Also fold the export modal's test-response Notice into the same VStack as the export options instead of wrapping it in a bespoke marginBottom div, so the 24px spacing comes from the VStack like every other child. * Forms: Swap export modal Notice to @wordpress/ui Notice component Replace the legacy @wordpress/components Notice (status="info", isDismissible) with the @wordpress/ui Notice.Root + Notice.Description (intent="info") so the test-response warning in the Export responses modal matches the rest of the new WPDS-based surfaces. * Forms: Don't fail PHPUnit run on deprecations The phpunit.11.xml.dist config sets failOnDeprecation="true", which on PHP 8.5 treats the ReflectionProperty::setAccessible() deprecation as a fatal signal — making the packages/forms test job exit non-zero even when every assertion passes. Pass --do-not-fail-on-deprecation to phpunit-select-config so the run succeeds on deprecation notices, and --display-deprecations so they still surface in the CI log for visibility. * Forms: Guard ReflectionProperty::setAccessible() by PHP version Replace the composer-level --do-not-fail-on-deprecation / --display-deprecations workaround with a version-guarded call at the source. PHP 8.1 made setAccessible() a no-op for all reflection objects and PHP 8.5 deprecates calling it on ReflectionProperty, so on 8.1+ the package-supported branch now skips the call entirely. The 7.2–8.0 codepath still invokes setAccessible() so the test keeps working against the package's minimum PHP. * Forms: Drop changelog entry for the PHPUnit deprecation guard The guard is purely a test-only change (never ships to users), so it doesn't need a changelog entry. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9c3b2ec commit 54cb1b0

22 files changed

Lines changed: 964 additions & 99 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
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.

projects/packages/forms/routes/responses/stage.tsx

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ type FeedbackFilters = {
5353

5454
const EMPTY_ARRAY = [];
5555

56+
// Sentinel value used in the Source filter to represent form-preview (test) responses.
57+
// Source IDs are numeric post IDs, so this non-numeric value is safe from collision.
58+
const FORM_PREVIEW_SOURCE_VALUE = 'form_preview';
59+
5660
const defaultLayouts = {
5761
table: {},
5862
list: {},
@@ -65,6 +69,7 @@ type QueryParams = {
6569
orderby?: string;
6670
order?: string;
6771
is_unread?: boolean;
72+
is_test?: boolean;
6873
parent?: string;
6974
source?: string;
7075
before?: string;
@@ -303,7 +308,11 @@ function StageInner() {
303308
queryArgs.is_unread = filter.value === 'unread';
304309
}
305310
if ( ! isSingleFormView && filter.field === 'source' ) {
306-
queryArgs.source = filter.value;
311+
if ( filter.value === FORM_PREVIEW_SOURCE_VALUE ) {
312+
queryArgs.is_test = true;
313+
} else {
314+
queryArgs.source = filter.value;
315+
}
307316
}
308317
if ( filter.field === 'date' ) {
309318
const filterValue: unknown = filter.value;
@@ -485,9 +494,16 @@ function StageInner() {
485494
/>
486495
{ styleUnreadValue(
487496
<Stack direction="column" gap="2xs">
488-
<Text ellipsizeMode="tail" limit={ 50 } truncate>
489-
{ displayName }
490-
</Text>
497+
<Stack direction="row" align="center" gap="xs">
498+
<Text ellipsizeMode="tail" limit={ 50 } truncate>
499+
{ displayName }
500+
</Text>
501+
{ item.is_test && (
502+
<Badge intent="none" aria-label={ __( 'Test response', 'jetpack-forms' ) }>
503+
{ __( 'Test', 'jetpack-forms' ) }
504+
</Badge>
505+
) }
506+
</Stack>
491507
{ showEmail && (
492508
<Text variant="muted" size={ 12 } ellipsizeMode="tail" limit={ 50 } truncate>
493509
{ item.author_email }
@@ -526,6 +542,18 @@ function StageInner() {
526542
id: 'source',
527543
label: __( 'Source', 'jetpack-forms' ),
528544
render: ( { item } ) => {
545+
// Test responses point at the regenerated preview URL instead of
546+
// the hosting page, and always surface as "Form preview".
547+
if ( item.is_test ) {
548+
const previewLabel = __( 'Form preview', 'jetpack-forms' );
549+
if ( item.preview_url ) {
550+
return styleUnreadValue(
551+
<ExternalLink href={ item.preview_url }>{ previewLabel }</ExternalLink>,
552+
item.is_unread
553+
);
554+
}
555+
return styleUnreadValue( previewLabel, item.is_unread );
556+
}
529557
const source =
530558
item.entry_title ||
531559
getUrlPath( item.entry_permalink ) ||
@@ -538,15 +566,19 @@ function StageInner() {
538566
}
539567
return styleUnreadValue( source, item.is_unread );
540568
},
541-
elements: ( ( filterOptions as unknown as FeedbackFilters )?.source || [] ).map(
542-
source => ( {
569+
elements: [
570+
{
571+
value: FORM_PREVIEW_SOURCE_VALUE,
572+
label: __( 'Form preview', 'jetpack-forms' ),
573+
},
574+
...( ( filterOptions as unknown as FeedbackFilters )?.source || [] ).map( source => ( {
543575
value: source.id.toString(),
544576
label:
545577
decodeEntities( source.title ) ||
546578
getUrlPath( source.url ) ||
547579
__( '(no title)', 'jetpack-forms' ),
548-
} )
549-
),
580+
} ) ),
581+
],
550582
filterBy: isSingleFormView ? false : { operators: [ 'is' ] as Operator[] },
551583
enableSorting: false,
552584
},

projects/packages/forms/src/contact-form/class-contact-form-endpoint.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,12 @@ public function register_routes() {
373373
'sanitize_callback' => 'rest_sanitize_boolean',
374374
'validate_callback' => 'rest_validate_request_arg',
375375
),
376+
'is_test' => array(
377+
'description' => 'Limit results to test responses or exclude them.',
378+
'type' => 'boolean',
379+
'sanitize_callback' => 'rest_sanitize_boolean',
380+
'validate_callback' => 'rest_validate_request_arg',
381+
),
376382
'source' => array(
377383
'description' => 'Limit results to feedback submitted from a specific source post ID.',
378384
'type' => 'integer',
@@ -477,6 +483,7 @@ public function get_status_counts( $request ) {
477483
$before = $request->get_param( 'before' );
478484
$after = $request->get_param( 'after' );
479485
$is_unread = $request->get_param( 'is_unread' );
486+
$is_test = $request->get_param( 'is_test' );
480487

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

519+
if ( null !== $is_test ) {
520+
$is_test_meta_key = esc_sql( Feedback::IS_TEST_META_KEY );
521+
$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}')";
522+
$where_conditions[] = $is_test ? "is_test_meta.meta_value = '1'" : 'is_test_meta.meta_id IS NULL';
523+
}
524+
512525
$where_clause = implode( ' AND ', $where_conditions );
513526

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

823+
$schema['properties']['is_test'] = array(
824+
'description' => __( 'Whether the form response was submitted from a form preview (test response).', 'jetpack-forms' ),
825+
'type' => 'boolean',
826+
'context' => array( 'view', 'edit', 'embed' ),
827+
'arg_options' => array(
828+
'sanitize_callback' => 'rest_sanitize_boolean',
829+
),
830+
'readonly' => true,
831+
);
832+
833+
$schema['properties']['preview_url'] = array(
834+
'description' => __( 'URL to the form preview that produced this response, when the response is a test submission.', 'jetpack-forms' ),
835+
'type' => array( 'string', 'null' ),
836+
'context' => array( 'view', 'edit', 'embed' ),
837+
'arg_options' => array(
838+
'sanitize_callback' => 'esc_url_raw',
839+
),
840+
'readonly' => true,
841+
);
842+
810843
$this->schema = $schema;
811844

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

1024+
if ( rest_is_field_included( 'is_test', $fields ) ) {
1025+
$data['is_test'] = $feedback_response->is_test();
1026+
}
1027+
1028+
if ( rest_is_field_included( 'preview_url', $fields ) ) {
1029+
$preview_url = null;
1030+
if ( $feedback_response->is_test() ) {
1031+
$form_id = $feedback_response->get_form_id();
1032+
if ( $form_id ) {
1033+
$preview_url = Form_Preview::generate_preview_url( (int) $form_id );
1034+
}
1035+
}
1036+
$data['preview_url'] = $preview_url;
1037+
}
1038+
9911039
$response->set_data( $data );
9921040

9931041
return rest_ensure_response( $response );
@@ -1080,6 +1128,25 @@ protected function prepare_items_query( $args = array(), $request = null ) {
10801128
add_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10, 2 );
10811129
}
10821130

1131+
// Filter by test/non-test responses via the _feedback_is_test meta.
1132+
$is_test = $request->get_param( 'is_test' );
1133+
if ( null !== $is_test ) {
1134+
$meta_query = isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : array();
1135+
if ( $is_test ) {
1136+
$meta_query[] = array(
1137+
'key' => Feedback::IS_TEST_META_KEY,
1138+
'value' => '1',
1139+
'compare' => '=',
1140+
);
1141+
} else {
1142+
$meta_query[] = array(
1143+
'key' => Feedback::IS_TEST_META_KEY,
1144+
'compare' => 'NOT EXISTS',
1145+
);
1146+
}
1147+
$args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1148+
}
1149+
10831150
return $args;
10841151
}
10851152

@@ -1149,6 +1216,12 @@ public function get_collection_params() {
11491216
'sanitize_callback' => 'rest_sanitize_boolean',
11501217
'validate_callback' => 'rest_validate_request_arg',
11511218
);
1219+
$query_params['is_test'] = array(
1220+
'description' => __( 'Limit result set to test responses (from form preview) or exclude them.', 'jetpack-forms' ),
1221+
'type' => 'boolean',
1222+
'sanitize_callback' => 'rest_sanitize_boolean',
1223+
'validate_callback' => 'rest_validate_request_arg',
1224+
);
11521225
$query_params['invalid_ids'] = array(
11531226
'description' => __( 'List of item IDs to include in results regardless of filters.', 'jetpack-forms' ),
11541227
'type' => 'array',

projects/packages/forms/src/contact-form/class-contact-form-plugin.php

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,11 +1607,6 @@ public static function recalculate_unread_count() {
16071607
* Conditionally attached to `template_redirect`
16081608
*/
16091609
public function process_form_submission() {
1610-
// Block submissions in preview mode.
1611-
if ( Form_Preview::is_preview_mode() ) {
1612-
return;
1613-
}
1614-
16151610
// Add a filter to replace tokens in the subject field with sanitized field values.
16161611
add_filter( 'contact_form_subject', array( $this, 'replace_tokens_with_input' ), 10, 2 );
16171612

@@ -1721,6 +1716,14 @@ public function process_form_submission() {
17211716
if ( Jetpack_Forms::is_webhooks_enabled() && ! empty( $form->attributes['webhooks'] ) ) {
17221717
Form_Webhooks::init();
17231718
}
1719+
1720+
// The decoded JWT carries a serialized Feedback_Source; when the
1721+
// form was rendered in preview mode that source has is_test=true.
1722+
// Flag the submission accordingly so the response is stored as a
1723+
// test response. JWTs issued before this feature shipped simply
1724+
// omit the flag and behave as regular submissions.
1725+
$form->set_is_preview_submission( $form->get_source()->is_test() );
1726+
17241727
// Process the form
17251728
return $form->process_submission();
17261729
}
@@ -2906,11 +2909,14 @@ public function personal_data_search_filter( $search ) {
29062909
/**
29072910
* Returns an array of feedback data for export.
29082911
*
2909-
* @param array $feedback_ids Array of feedback IDs to fetch the data for.
2912+
* @param array $feedback_ids Array of feedback IDs to fetch the data for.
2913+
* @param bool $include_test_responses Whether to include feedback that was submitted
2914+
* from form preview. Defaults to false, meaning
2915+
* preview/test responses are excluded from the export.
29102916
*
29112917
* @return array
29122918
*/
2913-
public function get_export_feedback_data( $feedback_ids ) {
2919+
public function get_export_feedback_data( $feedback_ids, $include_test_responses = false ) {
29142920
$feedback_data = array();
29152921
$all_field_names = array();
29162922

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

2930+
// Skip test responses from form preview unless explicitly requested.
2931+
if ( ! $include_test_responses && $response->is_test() ) {
2932+
continue;
2933+
}
2934+
29242935
// Get fields with automatic duplicate handling (label-value shape includes counts)
29252936
$compiled_fields = $response->get_compiled_fields( 'csv', 'label-value' );
29262937

@@ -3078,7 +3089,8 @@ public function get_feedback_entries_from_post() {
30783089
}
30793090
}
30803091

3081-
if ( ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] ) ) {
3092+
$has_explicit_selection = ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] );
3093+
if ( $has_explicit_selection ) {
30823094
$args['include'] = array_filter(
30833095
array_map(
30843096
function ( $selected ) {
@@ -3125,7 +3137,11 @@ function ( $selected ) {
31253137
}
31263138
}
31273139

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

31313147
/**

0 commit comments

Comments
 (0)