Skip to content

Commit 7b88e27

Browse files
enejbclaude
andcommitted
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>
1 parent 80ea3fd commit 7b88e27

18 files changed

Lines changed: 978 additions & 76 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: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,38 @@ function getUrlPath( url: string ): string | null {
145145
}
146146
}
147147

148+
/**
149+
* Badge surfacing that a response came from form preview.
150+
*
151+
* When a preview URL is available, the badge is wrapped in a link pointing
152+
* to the preview so the form owner can re-open it. Otherwise it renders as
153+
* a plain label.
154+
*
155+
* @param props - Component props.
156+
* @param props.previewUrl - URL to the form preview, or null/undefined.
157+
* @return The badge element.
158+
*/
159+
function FormPreviewBadge( { previewUrl }: { previewUrl?: string | null } ) {
160+
const label = __( 'Form Preview', 'jetpack-forms' );
161+
const badge = <Badge intent="none">{ label }</Badge>;
162+
163+
if ( ! previewUrl ) {
164+
return badge;
165+
}
166+
167+
return (
168+
<a
169+
href={ previewUrl }
170+
target="_blank"
171+
rel="noreferrer"
172+
style={ { textDecoration: 'none' } }
173+
aria-label={ __( 'Open form preview in a new tab', 'jetpack-forms' ) }
174+
>
175+
{ badge }
176+
</a>
177+
);
178+
}
179+
148180
/**
149181
* Stage component for the form responses DataViews.
150182
*
@@ -432,9 +464,14 @@ function StageInner() {
432464
/>
433465
{ styleUnreadValue(
434466
<Stack direction="column" gap="2xs">
435-
<Text ellipsizeMode="tail" limit={ 50 } truncate>
436-
{ displayName }
437-
</Text>
467+
<Stack direction="row" align="center" gap="xs">
468+
{ item.is_test && (
469+
<Badge intent="informational">{ __( 'Test', 'jetpack-forms' ) }</Badge>
470+
) }
471+
<Text ellipsizeMode="tail" limit={ 50 } truncate>
472+
{ displayName }
473+
</Text>
474+
</Stack>
438475
{ showEmail && (
439476
<Text variant="muted" size={ 12 } ellipsizeMode="tail" limit={ 50 } truncate>
440477
{ item.author_email }
@@ -479,6 +516,14 @@ function StageInner() {
479516
id: 'source',
480517
label: __( 'Source', 'jetpack-forms' ),
481518
render: ( { item } ) => {
519+
// Test responses (submitted from form preview) don't point at a
520+
// real source page — surface a Form Preview badge in place of the link.
521+
if ( item.is_test ) {
522+
return styleUnreadValue(
523+
<FormPreviewBadge previewUrl={ item.preview_url } />,
524+
item.is_unread
525+
);
526+
}
482527
const source =
483528
item.entry_title ||
484529
getUrlPath( item.entry_permalink ) ||

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,26 @@ public function get_item_schema() {
807807
'readonly' => true,
808808
);
809809

810+
$schema['properties']['is_test'] = array(
811+
'description' => __( 'Whether the form response was submitted from a form preview (test response).', 'jetpack-forms' ),
812+
'type' => 'boolean',
813+
'context' => array( 'view', 'edit', 'embed' ),
814+
'arg_options' => array(
815+
'sanitize_callback' => 'rest_sanitize_boolean',
816+
),
817+
'readonly' => true,
818+
);
819+
820+
$schema['properties']['preview_url'] = array(
821+
'description' => __( 'URL to the form preview that produced this response, when the response is a test submission.', 'jetpack-forms' ),
822+
'type' => array( 'string', 'null' ),
823+
'context' => array( 'view', 'edit', 'embed' ),
824+
'arg_options' => array(
825+
'sanitize_callback' => 'esc_url_raw',
826+
),
827+
'readonly' => true,
828+
);
829+
810830
$this->schema = $schema;
811831

812832
return $this->add_additional_fields_schema( $this->schema );
@@ -988,6 +1008,21 @@ public function prepare_item_for_response( $item, $request ) {
9881008
$data['is_unread'] = $feedback_response->is_unread();
9891009
}
9901010

1011+
if ( rest_is_field_included( 'is_test', $fields ) ) {
1012+
$data['is_test'] = $feedback_response->is_test();
1013+
}
1014+
1015+
if ( rest_is_field_included( 'preview_url', $fields ) ) {
1016+
$preview_url = null;
1017+
if ( $feedback_response->is_test() ) {
1018+
$form_id = $feedback_response->get_form_id();
1019+
if ( $form_id ) {
1020+
$preview_url = Form_Preview::generate_preview_url( (int) $form_id );
1021+
}
1022+
}
1023+
$data['preview_url'] = $preview_url;
1024+
}
1025+
9911026
$response->set_data( $data );
9921027

9931028
return rest_ensure_response( $response );

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

Lines changed: 42 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

@@ -1625,6 +1620,24 @@ public function process_form_submission() {
16251620
return Form_Submission_Error::system_error( 'invalid_form_id_or_hash', __( 'Invalid form ID or hash.', 'jetpack-forms' ) );
16261621
}
16271622

1623+
// Detect and authorize submissions coming from form preview. When a preview
1624+
// submission is detected we let it flow through the normal pipeline but
1625+
// flag the resulting feedback as a test response.
1626+
$is_preview_submission = false;
1627+
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce is verified inside verify_preview_submission().
1628+
if ( ! empty( $_POST[ Form_Preview::PREVIEW_SUBMIT_FIELD ] ) ) {
1629+
$preview_form_id = is_numeric( $id ) ? (int) $id : 0;
1630+
if ( $preview_form_id > 0 && Form_Preview::verify_preview_submission( $preview_form_id ) ) {
1631+
$is_preview_submission = true;
1632+
} else {
1633+
// Preview flag was present but nonce/caps did not check out. Reject.
1634+
return Form_Submission_Error::system_error(
1635+
'invalid_preview_submission',
1636+
__( 'Invalid form preview submission.', 'jetpack-forms' )
1637+
);
1638+
}
1639+
}
1640+
16281641
if ( is_user_logged_in() ) {
16291642
check_admin_referer( "contact-form_{$id}" );
16301643
}
@@ -1721,6 +1734,9 @@ public function process_form_submission() {
17211734
if ( Jetpack_Forms::is_webhooks_enabled() && ! empty( $form->attributes['webhooks'] ) ) {
17221735
Form_Webhooks::init();
17231736
}
1737+
// Flag the submission as originating from a form preview so the form
1738+
// treats the resulting feedback as a test response.
1739+
$form->set_is_preview_submission( $is_preview_submission );
17241740
// Process the form
17251741
return $form->process_submission();
17261742
}
@@ -1909,6 +1925,10 @@ public function process_form_submission() {
19091925
Form_Webhooks::init();
19101926
}
19111927

1928+
// Flag the submission as originating from a form preview so the form
1929+
// treats the resulting feedback as a test response.
1930+
$form->set_is_preview_submission( $is_preview_submission );
1931+
19121932
// Process the form
19131933
return $form->process_submission();
19141934
}
@@ -2906,11 +2926,14 @@ public function personal_data_search_filter( $search ) {
29062926
/**
29072927
* Returns an array of feedback data for export.
29082928
*
2909-
* @param array $feedback_ids Array of feedback IDs to fetch the data for.
2929+
* @param array $feedback_ids Array of feedback IDs to fetch the data for.
2930+
* @param bool $include_test_responses Whether to include feedback that was submitted
2931+
* from form preview. Defaults to false, meaning
2932+
* preview/test responses are excluded from the export.
29102933
*
29112934
* @return array
29122935
*/
2913-
public function get_export_feedback_data( $feedback_ids ) {
2936+
public function get_export_feedback_data( $feedback_ids, $include_test_responses = false ) {
29142937
$feedback_data = array();
29152938
$all_field_names = array();
29162939

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

2947+
// Skip test responses from form preview unless explicitly requested.
2948+
if ( ! $include_test_responses && $response->is_test() ) {
2949+
continue;
2950+
}
2951+
29242952
// Get fields with automatic duplicate handling (label-value shape includes counts)
29252953
$compiled_fields = $response->get_compiled_fields( 'csv', 'label-value' );
29262954

@@ -3078,7 +3106,8 @@ public function get_feedback_entries_from_post() {
30783106
}
30793107
}
30803108

3081-
if ( ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] ) ) {
3109+
$has_explicit_selection = ! empty( $_POST['selected'] ) && is_array( $_POST['selected'] );
3110+
if ( $has_explicit_selection ) {
30823111
$args['include'] = array_filter(
30833112
array_map(
30843113
function ( $selected ) {
@@ -3091,7 +3120,11 @@ function ( $selected ) {
30913120

30923121
$feedbacks = get_posts( $args );
30933122

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

30973130
/**

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

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ class Contact_Form extends Contact_Form_Shortcode {
137137
*/
138138
public $has_verified_jwt = false;
139139

140+
/**
141+
* Whether the current submission originated from an authenticated form preview.
142+
*
143+
* When true, the resulting feedback is marked as a test submission — Akismet
144+
* is skipped, the notification email is annotated, and the response is
145+
* excluded from the default CSV export.
146+
*
147+
* @var bool
148+
*/
149+
private $is_preview_submission = false;
150+
140151
/**
141152
* The source of the feedback entry.
142153
*
@@ -619,6 +630,25 @@ public function set_source( $source ) {
619630
$this->source = $source;
620631
}
621632

633+
/**
634+
* Flag whether the current submission originated from an authenticated form preview.
635+
*
636+
* @param bool $is_preview_submission Whether the submission came from form preview.
637+
* @return void
638+
*/
639+
public function set_is_preview_submission( $is_preview_submission ) {
640+
$this->is_preview_submission = (bool) $is_preview_submission;
641+
}
642+
643+
/**
644+
* Whether the current submission is a test submission coming from form preview.
645+
*
646+
* @return bool
647+
*/
648+
public function is_preview_submission() {
649+
return $this->is_preview_submission;
650+
}
651+
622652
/**
623653
* Get the context for the contact form based on the attributes and post.
624654
*
@@ -1249,7 +1279,6 @@ public static function parse( $attributes, $content, $context = array() ) {
12491279
'invalid_form_empty' => __( 'The form you are trying to submit is empty.', 'jetpack-forms' ),
12501280
'invalid_form' => __( 'Please fill out the form correctly.', 'jetpack-forms' ),
12511281
'network_error' => __( 'Connection issue while submitting the form. Check that you are connected to the Internet and try again.', 'jetpack-forms' ),
1252-
'preview_mode' => __( 'Form submissions are disabled in preview mode.', 'jetpack-forms' ),
12531282
),
12541283
'admin_ajax_url' => admin_url( 'admin-ajax.php' ),
12551284
);
@@ -2602,6 +2631,16 @@ public function process_submission() {
26022631

26032632
$response = Feedback::from_submission( $_POST, $this ); // phpcs:Ignore WordPress.Security.NonceVerification.Missing
26042633
$response->set_source( $this->get_source() );
2634+
2635+
// If the submission came from an authenticated form preview, flag the
2636+
// feedback as a test submission. The rest of the pipeline reads the
2637+
// flag from the feedback (which also travels into the serialized
2638+
// post_content via Feedback_Source).
2639+
if ( $this->is_preview_submission ) {
2640+
$response->mark_as_test();
2641+
}
2642+
$is_test_submission = $response->is_test();
2643+
26052644
$plugin = Contact_Form_Plugin::init();
26062645

26072646
$id = $this->get_attribute( 'id' );
@@ -2692,9 +2731,15 @@ public function process_submission() {
26922731
$spam = '';
26932732
$akismet_values = $plugin->prepare_for_akismet( $akismet_vars );
26942733

2695-
// Is it spam?
2696-
/** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
2697-
$is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2734+
// Is it spam? Test submissions (from form preview) skip Akismet entirely —
2735+
// the form owner is explicitly running a test and we don't want Akismet
2736+
// to learn from synthetic data or bounce the submission.
2737+
if ( $is_test_submission ) {
2738+
$is_spam = false;
2739+
} else {
2740+
/** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
2741+
$is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2742+
}
26982743
if ( is_wp_error( $is_spam ) ) { // WP_Error to abort
26992744
return $is_spam; // abort
27002745
} elseif ( $is_spam === true ) { // TRUE to flag a spam
@@ -2790,6 +2835,22 @@ public function process_submission() {
27902835

27912836
$entry_values = $response->get_entry_values();
27922837

2838+
// Prefix the subject with [TEST] for test submissions so the form owner
2839+
// can immediately tell this email came from a preview-mode submission.
2840+
if ( $is_test_submission ) {
2841+
/**
2842+
* Filter the subject prefix applied to test (preview) feedback emails.
2843+
*
2844+
* @module contact-form
2845+
*
2846+
* @since $$next-version$$
2847+
*
2848+
* @param string $prefix Default subject prefix for test submissions.
2849+
*/
2850+
$test_prefix = apply_filters( 'jetpack_forms_test_subject_prefix', '[TEST] ' );
2851+
$contact_form_subject = $test_prefix . $contact_form_subject;
2852+
}
2853+
27932854
/** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
27942855
$subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values );
27952856

@@ -2901,6 +2962,7 @@ public function process_submission() {
29012962
'comment_author_email' => $comment_author_email,
29022963
'comment_author_ip' => $comment_author_ip,
29032964
'is_spam' => $is_spam,
2965+
'is_test' => $is_test_submission,
29042966
'feedback_status' => $feedback_status,
29052967
);
29062968
$email = Feedback_Email_Renderer::build_email_content( $post_id, $this, $response, $context_data );
@@ -2937,6 +2999,25 @@ public function process_submission() {
29372999
$send_email = ( $this->get_attribute( 'emailNotifications' ) !== 'no' );
29383000
}
29393001

3002+
// Test submissions always send the notification email (so the form
3003+
// owner can verify their email flow end-to-end) regardless of the
3004+
// emailNotifications attribute. Site admins who want to opt out can
3005+
// return false from the filter below.
3006+
if ( $is_test_submission ) {
3007+
/**
3008+
* Filter whether test (preview) submissions should trigger the notification email.
3009+
*
3010+
* @module contact-form
3011+
*
3012+
* @since $$next-version$$
3013+
*
3014+
* @param bool $send Whether to send the test submission email. Default true.
3015+
* @param int $post_id The feedback post ID.
3016+
* @param Feedback $response The feedback response object.
3017+
*/
3018+
$send_email = apply_filters( 'jetpack_forms_send_test_feedback_email', true, $post_id, $response );
3019+
}
3020+
29403021
/**
29413022
* Filter to determine if spam should still be emailed.
29423023
*

0 commit comments

Comments
 (0)