Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Forms: Display success info after form submission without reload
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,7 @@ class="jetpack-form-file-field__container"
data-wp-on--dragleave="actions.dragLeave"
data-wp-on--mouseleave="actions.dragLeave"
data-wp-on--drop="actions.fileDropped"
data-wp-on--jetpack-form-reset="actions.resetFiles"
data-is-required="<?php echo esc_attr( $required ); ?>"
>
<div class="jetpack-form-file-field__dropzone" data-wp-class--is-dropping="context.isDropping" data-wp-class--is-hidden="state.hasMaxFiles">
Expand Down Expand Up @@ -1888,7 +1889,7 @@ public function render_field( $type, $id, $label, $value, $class, $placeholder,
$interactivity_attrs = ''; // Reset interactivity attributes for the field wrapper.
}

$field .= "\n<div {$block_style} {$interactivity_attrs} {$shell_field_class} data-wp-init='callbacks.initializeField' >\n"; // new in Jetpack 6.8.0
$field .= "\n<div {$block_style} {$interactivity_attrs} {$shell_field_class} data-wp-init='callbacks.initializeField' data-wp-on--jetpack-form-reset='callbacks.initializeField' >\n"; // new in Jetpack 6.8.0

switch ( $type ) {
case 'email':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,12 @@ protected function __construct() {
wp_style_add_data( 'grunion.css', 'rtl', 'replace' );

$config = array(
'error_types' => array(
'error_types' => array(
'is_required' => __( 'This field is required.', 'jetpack-forms' ),
'invalid_form_empty' => __( 'The form you are trying to submit is empty.', 'jetpack-forms' ),
'invalid_form' => __( 'Please fill out the form correctly.', 'jetpack-forms' ),
),
'admin_ajax_url' => admin_url( 'admin-ajax.php' ),
);
wp_interactivity_config( 'jetpack/form', $config );
\wp_enqueue_script_module(
Expand Down
212 changes: 157 additions & 55 deletions projects/packages/forms/src/contact-form/class-contact-form.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use PHPMailer\PHPMailer\PHPMailer;
use WP_Block;
use WP_Error;
use WP_Post;

/**
* Class for the contact-form shortcode.
Expand Down Expand Up @@ -78,6 +79,20 @@ class Contact_Form extends Contact_Form_Shortcode {
*/
public static $allowed_html_tags_for_submit_button = array( 'br' => array() );

/**
* Whether to enable response without reloading the page.
*
* @var bool
*/
public $is_response_without_reload_enabled = false;

/**
* The current post object for this form.
*
* @var WP_Post|null
*/
public $current_post;

/**
* Construction function.
*
Expand All @@ -87,6 +102,18 @@ class Contact_Form extends Contact_Form_Shortcode {
public function __construct( $attributes, $content = null ) {
global $post, $page;

// AJAX requests don't have a post object, so we need to get the post object from the $_POST['contact-form-id']
$this->current_post = $post;

// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verification happens in process_form_submission() for logged-in users
if ( ! $this->current_post && isset( $_POST['contact-form-id'] ) ) {
$contact_form_id = sanitize_text_field( wp_unslash( $_POST['contact-form-id'] ) );
$this->current_post = get_post( $contact_form_id );
}
// phpcs:enable

$this->is_response_without_reload_enabled = apply_filters( 'jetpack_forms_enable_ajax_submission', false );

// Set up the default subject and recipient for this form.
$default_to = '';
$default_subject = '[' . get_option( 'blogname' ) . ']';
Expand All @@ -95,12 +122,12 @@ public function __construct( $attributes, $content = null ) {
$attributes = array();
}

if ( $post ) {
if ( $this->current_post ) {
$default_subject = sprintf(
// translators: the blog name and post title.
_x( '%1$s %2$s', '%1$s = blog name, %2$s = post title', 'jetpack-forms' ),
$default_subject,
Contact_Form_Plugin::strip_tags( $post->post_title )
Contact_Form_Plugin::strip_tags( $this->current_post->post_title )
);
}

Expand All @@ -115,17 +142,18 @@ public function __construct( $attributes, $content = null ) {
} elseif ( ! empty( $attributes['block_template_part'] ) && $attributes['block_template_part'] ) {
$default_to .= get_option( 'admin_email' );
$attributes['id'] = 'block-template-part-' . $attributes['block_template_part'];
} elseif ( $post ) {
$attributes['id'] = $post->ID;
$post_author = get_userdata( $post->post_author );
} elseif ( $this->current_post ) {
$attributes['id'] = $this->current_post->ID;
$post_author = get_userdata( $this->current_post->post_author );
if ( is_a( $post_author, '\WP_User' ) ) {
$default_to .= $post_author->user_email;
} else {
$default_to .= get_option( 'admin_email' );
}
}

if ( ! empty( self::$forms ) ) {
// When using admin-ajax.php, we don't need to add a page number to the id
if ( ! empty( self::$forms ) && ! $this->is_response_without_reload_enabled ) {
// Ensure 'id' exists in $attributes before trying to modify it
if ( ! isset( $attributes['id'] ) ) {
$attributes['id'] = '';
Expand Down Expand Up @@ -328,8 +356,56 @@ public static function parse( $attributes, $content, $context = array() ) {
$container_classes[] = self::get_block_alignment_class( $attributes );
$container_classes_string = implode( ' ', $container_classes );

$max_steps = 0;
if ( preg_match_all( '/data-wp-context=[\'"]?{"step":(\d+)}[\'"]?/', $content, $matches ) ) {
if ( ! empty( $matches[1] ) ) {
$max_steps = max( array_map( 'intval', $matches[1] ) );
}
}

$is_multistep = $max_steps > 0;
$element_id = 'jp-form-' . esc_attr( $form->hash );

$default_context = array(
'formId' => $id,
'formHash' => $form->hash,
'showErrors' => false, // We toggle this to true when we want to show the user errors right away.
'errors' => array(), // This should be a associative array.
'fields' => array(),
'isMultiStep' => $is_multistep, // Whether the form is a multistep form.
'isResponseWithoutReloadEnabled' => $form->is_response_without_reload_enabled,
'submissionData' => null,
'submissionError' => null,
'elementId' => $element_id,
);

if ( $is_multistep ) {
$multistep_context = array(
'currentStep' => isset( $_GET[ $id . '-step' ] ) ? absint( $_GET[ $id . '-step' ] ) : 1, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'maxSteps' => $max_steps,
'direction' => 'forward', // Default direction for animations
'transition' => $form->get_attribute( 'stepTransition' ) ? $form->get_attribute( 'stepTransition' ) : 'fade-slide', // Transition style for step animations
);

if ( ! is_array( $context ) ) {
$context = array();
}
$context = array_merge( $context, $multistep_context );
}

$context = is_array( $context ) ? array_merge( $default_context, $context ) : $default_context;

$r = '';
$r .= "<div data-test='contact-form' id='contact-form-$id' class='{$container_classes_string}'>\n";
$r .= "<div data-test='contact-form'
id='contact-form-$id'
class='{$container_classes_string}'
data-wp-interactive='jetpack/form' " . wp_interactivity_data_wp_context( $context ) . "
data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\"
>\n";

if ( $form->is_response_without_reload_enabled ) {
$r .= self::render_ajax_success_wrapper( $form );
}

if ( is_wp_error( $form->errors ) && $form->errors->get_error_codes() ) {
// There are errors. Display them
Expand Down Expand Up @@ -418,49 +494,16 @@ public static function parse( $attributes, $content, $context = array() ) {
$form_classes .= ' wp-block-jetpack-contact-form';
}

$max_steps = 0;
if ( preg_match_all( '/data-wp-context=[\'"]?{"step":(\d+)}[\'"]?/', $content, $matches ) ) {
if ( ! empty( $matches[1] ) ) {
$max_steps = max( array_map( 'intval', $matches[1] ) );
}
}

$is_multistep = $max_steps > 0;

$default_context = array(
'formId' => $id,
'formHash' => $form->hash,
'showErrors' => false, // We toggle this to true when we want to show the user errors right away.
'errors' => array(), // This should be a associative array.
'fields' => array(),
'isMultiStep' => $is_multistep, // Whether the form is a multistep form.
'isAjaxSubmissionEnabled' => apply_filters( 'jetpack_forms_enable_ajax_submission', false ),
);

if ( $is_multistep ) {
$multistep_context = array(
'currentStep' => isset( $_GET[ $id . '-step' ] ) ? absint( $_GET[ $id . '-step' ] ) : 1,
'maxSteps' => $max_steps,
'direction' => 'forward', // Default direction for animations
'transition' => $form->get_attribute( 'stepTransition' ) ? $form->get_attribute( 'stepTransition' ) : 'fade-slide', // Transition style for step animations
);

if ( ! is_array( $context ) ) {
$context = array();
}
$context = array_merge( $context, $multistep_context );
}

$context = is_array( $context ) ? array_merge( $default_context, $context ) : $default_context;

$r .= "<form action='" . esc_url( $url ) . "'
id='jp-form-" . esc_attr( $form->hash ) . "'
id='" . $element_id . "'
method='post'
class='" . esc_attr( $form_classes ) . "' $form_aria_label
data-wp-interactive=\"jetpack/form\" " . wp_interactivity_data_wp_context( $context ) . "
data-wp-on--submit=\"actions.onFormSubmit\"
data-wp-on--reset=\"actions.onFormReset\"
data-wp-class--is-submitted=\"state.hasSubmitted\"
data-wp-class--is-first-step=\"state.isFirstStep\"
data-wp-class--is-last-step=\"state.isLastStep\"
data-wp-class--is-ajax-form=\"context.isResponseWithoutReloadEnabled\"
novalidate >\n";

if ( $is_multistep ) { // This makes the "enter" key work in multi-step forms as expected.
Expand Down Expand Up @@ -577,6 +620,49 @@ private static function render_error_wrapper() {
return $html;
}

/**
* Renders the success wrapper after a form is submitted without reloading the page.
*
* @param Contact_Form $form - the contact form.
*
* @return string HTML string for the success wrapper.
*/
private static function render_ajax_success_wrapper( $form ) {
$html = '<div class="contact-form-submission contact-form-ajax-submission" data-wp-class--is-submitted="state.hasSubmitted">';
$html .= '<p class="go-back-message"> <a class="link" href="#" data-wp-on--click="actions.goBack">' . esc_html__( 'Go back', 'jetpack-forms' ) . '</a> </p>';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the CSS classes these for backwards compatibility, or would it make sense to add jetpack- prefix to scope them better?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are for backwards compatibility, yes. That is almost a copy of the current post-submission screen.

$html .=
'<h4 id="contact-form-success-header">' . esc_html( $form->get_attribute( 'customThankyouHeading' ) ) .
"</h4>\n\n";

if ( 'message' === $form->get_attribute( 'customThankyou' ) ) {
$raw_message = wpautop( $form->get_attribute( 'customThankyouMessage' ) );
// Add more allowed HTML elements for file download links
Copy link
Member

@simison simison Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why be restrictive here at all instead of just using wp_kses()'s default restrictions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is the current implementation on the post-submission screen. The idea is to match it as well as possible, so I copied it over.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense then 👍

$allowed_html = array(
'br' => array(),
'blockquote' => array( 'class' => array() ),
'p' => array(),
'div' => array(
'class' => array(),
'style' => array(),
),
'span' => array(
'class' => array(),
'style' => array(),
),
);

$html .= wp_kses( $raw_message, $allowed_html );
} else {
$html .= '<template data-wp-each--submission="state.getSubmissionData">
<div class="field-name" data-wp-text="context.submission.label" data-wp-bind--hidden="!context.submission.label"></div>
<div class="field-value" data-wp-text="context.submission.value"></div>
Copy link
Member

@simison simison Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as https://github.com/Automattic/jetpack/pull/44204/files#r2189419246 — any reason not to prefix class with jetpack-? This one is particularly generic and can collide with theme/other plugins.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can prefix them, yes. I just need to make sure the prefixed classes also work on the current screen. Will add it in this PR.

</template>';
}

$html .= '</div>';
return $html;
}

/**
* Returns a success message to be returned if the form is sent via AJAX.
*
Expand Down Expand Up @@ -1240,8 +1326,6 @@ public function get_field_ids() {
* Stores feedback. Sends email.
*/
public function process_submission() {
global $post;

$plugin = Contact_Form_Plugin::init();

$id = $this->get_attribute( 'id' );
Expand Down Expand Up @@ -1295,7 +1379,7 @@ public function process_submission() {
if ( isset( $_POST['contact-form-id'] ) && 'block-template-part-' . $block_template_part !== $_POST['contact-form-id'] ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
return false;
}
} elseif ( isset( $_POST['contact-form-id'] ) && ( empty( $post ) || $post->ID !== (int) $_POST['contact-form-id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
} elseif ( isset( $_POST['contact-form-id'] ) && ( empty( $this->current_post ) || $this->current_post->ID !== (int) sanitize_text_field( wp_unslash( $_POST['contact-form-id'] ) ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
return false;
}

Expand Down Expand Up @@ -1575,9 +1659,29 @@ public function process_submission() {
$feedback_title = "{$comment_author} - {$feedback_time}";
$feedback_id = md5( $feedback_title );

$entry_title = '';
$entry_permalink = '';

if ( $this->current_post ) {
$entry_title = $this->current_post->post_title;
$entry_permalink = esc_url( self::get_permalink( $this->current_post->ID ) );
} elseif ( $widget ) {
$entry_title = __( 'Sidebar Widget', 'jetpack-forms' );
$entry_permalink = esc_url( home_url( '/' ) );
} elseif ( $block_template ) {
$entry_title = __( 'Block Template', 'jetpack-forms' );
$entry_permalink = esc_url( home_url( '/' ) );
} elseif ( $block_template_part ) {
$entry_title = __( 'Block Template Part', 'jetpack-forms' );
$entry_permalink = esc_url( home_url( '/' ) );
} else {
$entry_title = the_title_attribute( 'echo=0' );
$entry_permalink = esc_url( self::get_permalink( get_the_ID() ) );
}

$entry_values = array(
'entry_title' => the_title_attribute( 'echo=0' ),
'entry_permalink' => esc_url( self::get_permalink( get_the_ID() ) ),
'entry_title' => $entry_title,
'entry_permalink' => $entry_permalink,
'feedback_id' => $feedback_id,
);

Expand All @@ -1596,7 +1700,7 @@ public function process_submission() {
if ( $block_template || $block_template_part || $widget ) {
$url = home_url( '/' );
} else {
$url = self::get_permalink( $post->ID );
$url = self::get_permalink( $this->current_post ? $this->current_post->ID : 0 );
}

// translators: the time of the form submission.
Expand Down Expand Up @@ -1661,7 +1765,7 @@ public function process_submission() {
'post_date' => addslashes( $feedback_time ),
'post_type' => 'feedback',
'post_status' => addslashes( $feedback_status ),
'post_parent' => $post ? (int) $post->ID : 0,
'post_parent' => $this->current_post ? (int) $this->current_post->ID : 0,
'post_title' => addslashes( wp_kses( $feedback_title, array() ) ),
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase, WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DevelopmentFunctions.error_log_print_r
'post_content' => addslashes( wp_kses( "$comment_content\n<!--more-->\nAUTHOR: {$comment_author}\nAUTHOR EMAIL: {$comment_author_email}\nAUTHOR URL: {$comment_author_url}\nSUBJECT: {$subject}\n{$comment_ip_text}JSON_DATA\n" . @wp_json_encode( $all_values, true ), array() ) ), // so that search will pick up this data
Expand Down Expand Up @@ -1884,16 +1988,14 @@ public function process_submission() {
do_action( 'grunion_after_message_sent', $post_id, $to, $subject, $message, $headers, $all_values, $extra_values );

// If the request accepts JSON, return a JSON response instead of redirecting
$is_ajax_submission_enabled = apply_filters( 'jetpack_forms_enable_ajax_submission', false );
$accepts_json = isset( $_SERVER['HTTP_ACCEPT'] ) && false !== strpos( strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ), 'application/json' );
$accepts_json = isset( $_SERVER['HTTP_ACCEPT'] ) && false !== strpos( strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ), 'application/json' );

if ( $is_ajax_submission_enabled && $accepts_json ) {
if ( $this->is_response_without_reload_enabled && $accepts_json ) {
header( 'Content-Type: application/json' );

echo wp_json_encode(
array(
'success' => true,
'message' => __( 'Your message has been sent', 'jetpack-forms' ),
'data' => self::get_json_data( $post_id, $this ),
)
);
Expand Down
8 changes: 8 additions & 0 deletions projects/packages/forms/src/contact-form/css/grunion.css
Original file line number Diff line number Diff line change
Expand Up @@ -1174,3 +1174,11 @@ on production builds, the attributes are being reordered, causing side-effects
white-space: nowrap;
width: 1px;
}

.contact-form.is-ajax-form.is-submitted {
display: none;
}

.contact-form-ajax-submission:not(.is-submitted) {
display: none;
}
8 changes: 8 additions & 0 deletions projects/packages/forms/src/modules/file-field/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,14 @@ const { state, actions } = store( NAMESPACE, {
xhr.send( formData );
},

/**
* Reset the files in the context.
*/
resetFiles: () => {
const context = getContext();
context.files = [];
},

/**
* Remove a file from the context and cancel its upload if in progress.
*
Expand Down
Loading
Loading