Skip to content
Open
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
4 changes: 4 additions & 0 deletions projects/packages/forms/changelog/add-forms-submit-timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Forms: add form fill duration to form entries
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,16 @@ public function get_item_schema() {
'readonly' => true,
);

$schema['properties']['form_fill_duration'] = array(
'description' => __( 'The duration in seconds from first user interaction to form submission. Returns null for submissions before this feature was added.', 'jetpack-forms' ),
'type' => array( 'integer', 'null' ),
'context' => array( 'view', 'edit', 'embed' ),
'arg_options' => array(
'sanitize_callback' => 'absint',
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

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

The sanitize_callback 'absint' will convert null values to 0, which is inconsistent with the schema type that allows null. This will prevent null values from being properly represented in the API response for older submissions. Consider using a custom sanitization callback that preserves null values or removing the sanitize_callback since the field is readonly and already sanitized during storage.

Suggested change
'sanitize_callback' => 'absint',

Copilot uses AI. Check for mistakes.
),
'readonly' => true,
);

$schema['properties']['browser'] = array(
'description' => __( 'The browser and platform used to submit the form.', 'jetpack-forms' ),
'type' => 'string',
Expand Down Expand Up @@ -809,6 +819,9 @@ public function prepare_item_for_response( $item, $request ) {
if ( rest_is_field_included( 'country_code', $fields ) ) {
$data['country_code'] = $feedback_response->get_country_code();
}
if ( rest_is_field_included( 'form_fill_duration', $fields ) ) {
$data['form_fill_duration'] = $feedback_response->get_form_fill_duration();
}
if ( rest_is_field_included( 'browser', $fields ) ) {
$data['browser'] = $feedback_response->get_browser();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,7 @@ public static function parse( $attributes, $content, $context = array() ) {
id='contact-form-$id'
class='{$container_classes_string}'
data-wp-interactive='jetpack/form' " . wp_interactivity_data_wp_context( $context ) . "
data-wp-on--focusin=\"callbacks.trackFirstInteraction\"
data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\"
>\n";

Expand Down Expand Up @@ -914,6 +915,7 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label
$r .= '<input type="submit" style="display: none;" />';
}
$r .= "<input type='hidden' name='jetpack_contact_form_jwt' value='" . esc_attr( $form->get_jwt() ) . "' />\n";
$r .= "<input type='hidden' name='form_fill_duration' value='0' />\n";
$r .= $form->body;

if ( $is_multistep ) {
Expand Down
47 changes: 35 additions & 12 deletions projects/packages/forms/src/contact-form/class-feedback.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ class Feedback {
*/
protected $country_code = null;

/**
* The form fill duration in seconds.
*
* Tracks how long the user spent filling out the form (from first interaction to submission).
*
* @var int|null
*/
protected $form_fill_duration = null;

/**
* The subject of the feedback entry.
*
Expand Down Expand Up @@ -234,10 +243,11 @@ private function load_from_post( WP_Post $feedback_post ) {
$parsed_content['request_url'] ?? ''
);

$this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
$this->country_code = $parsed_content['country_code'] ?? null;
$this->user_agent = $parsed_content['user_agent'] ?? null;
$this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );
$this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
$this->country_code = $parsed_content['country_code'] ?? null;
$this->user_agent = $parsed_content['user_agent'] ?? null;
$this->form_fill_duration = $parsed_content['form_fill_duration'] ?? null;
$this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );

$this->notification_recipients = $parsed_content['notification_recipients'] ?? array();

Expand Down Expand Up @@ -290,14 +300,15 @@ private function load_from_submission( $post_data, $form, $current_post = null,

$this->source = Feedback_Source::from_submission( $current_post, $current_page_number );
// If post_data is provided, use it to populate fields.
$this->fields = $this->get_computed_fields( $post_data, $form );
$this->ip_address = Contact_Form_Plugin::get_ip_address();
$this->country_code = $this->get_country_code_from_ip( $this->ip_address );
$this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
$this->subject = $this->get_computed_subject( $post_data, $form );
$this->author_data = Feedback_Author::from_submission( $post_data, $form );
$this->comment_content = $this->get_computed_comment_content( $post_data, $form );
$this->has_consent = $this->get_computed_consent( $post_data, $form );
$this->fields = $this->get_computed_fields( $post_data, $form );
$this->ip_address = Contact_Form_Plugin::get_ip_address();
$this->country_code = $this->get_country_code_from_ip( $this->ip_address );
$this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
$this->form_fill_duration = isset( $post_data['form_fill_duration'] ) ? absint( $post_data['form_fill_duration'] ) : null;
$this->subject = $this->get_computed_subject( $post_data, $form );
$this->author_data = Feedback_Author::from_submission( $post_data, $form );
$this->comment_content = $this->get_computed_comment_content( $post_data, $form );
$this->has_consent = $this->get_computed_consent( $post_data, $form );

$this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form );

Expand Down Expand Up @@ -785,6 +796,17 @@ public function get_country_code() {
return $this->country_code;
}

/**
* Get the form fill duration in seconds.
*
* Represents the time from first user interaction to form submission.
*
* @return int|null
*/
public function get_form_fill_duration() {
return $this->form_fill_duration;
}

/**
* Get the emoji flag for the country.
*
Expand Down Expand Up @@ -1213,6 +1235,7 @@ public function serialize() {
'ip' => $this->ip_address,
'country_code' => $this->country_code,
'user_agent' => $this->user_agent,
'form_fill_duration' => $this->form_fill_duration,
'notification_recipients' => $this->notification_recipients,
),
$this->source->serialize()
Expand Down
20 changes: 20 additions & 0 deletions projects/packages/forms/src/modules/form/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,18 @@ const { state, actions } = store( NAMESPACE, {
event.stopPropagation();
context.submissionError = null;

// Calculate and set the form fill duration before submission
if ( context.formFirstInteractionTime ) {
const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds
const form = document.getElementById( 'jp-form-' + context.formHash );
if ( form ) {
const durationField = form.querySelector( 'input[name="form_fill_duration"]' );
if ( durationField ) {
durationField.value = duration;
}
}
}
Comment on lines +469 to +479
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

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

The form fill duration is only calculated when context.useAjax is true (line 464). For non-AJAX form submissions, the duration field will remain at its default value of '0'. The duration calculation logic should be moved before the if ( context.useAjax ) check (after line 462) to ensure it works for both AJAX and non-AJAX submissions.

Copilot uses AI. Check for mistakes.

const { success, error, data, refreshArgs } = yield submitForm( context.formHash );

if ( success ) {
Expand Down Expand Up @@ -558,6 +570,14 @@ const { state, actions } = store( NAMESPACE, {
registerField( fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra );
},

trackFirstInteraction() {
const context = getContext();
// Store the first interaction time when user focuses on any form field
if ( ! context.formFirstInteractionTime ) {
context.formFirstInteractionTime = Date.now();
}
},

scrollToWrapper() {
const context = getContext();

Expand Down
38 changes: 38 additions & 0 deletions projects/packages/forms/tests/php/contact-form/Feedback_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,44 @@ public function test_country_code_included_in_serialized_response() {
remove_filter( 'jetpack_get_country_from_ip', $filter_callback, 10 );
}

/**
* Test that form_fill_duration is included in serialized response and persists after save/load.
*/
public function test_form_fill_duration_persists_after_save() {

$form_id = Utility::get_form_id();
// Create a form submission with form_fill_duration
$_post_data = Utility::get_post_request(
array(
'name' => 'John Doe',
'email' => '[email protected]',
'message' => 'Test message',
'form_fill_duration' => '45', // 45 seconds
),
'g' . $form_id
);

$form = new Contact_Form(
array(
'title' => 'Test Form',
'description' => 'This is a test form.',
),
"[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]"
);

// Create a contact form response
$response = Feedback::from_submission( $_post_data, $form );
$feedback_post_id = $response->save();
$saved_response = Feedback::get( $feedback_post_id );

// The form_fill_duration should be present and match the test value.
$this->assertNotEmpty( $response->get_form_fill_duration(), 'Form fill duration should not be empty' );
$this->assertNotEmpty( $saved_response->get_form_fill_duration(), 'Form fill duration should not be empty after save/load' );
$this->assertEquals( $response->get_form_fill_duration(), $saved_response->get_form_fill_duration(), 'Form fill duration should match after save/load' );
$this->assertEquals( 45, $saved_response->get_form_fill_duration(), 'Form fill duration should be 45 seconds' );
$this->assertIsInt( $saved_response->get_form_fill_duration(), 'Form fill duration should be an integer' );
}

/**
* Test the browser information is parsed correctly from user agent.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ public static function get_post_request( $values, $form_id = null, $post_id = 0
$prefix = $form_id ? $form_id : 'g' . $post_id;
$post_data = array();
foreach ( $values as $key => $val ) {
if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 ) {
if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 || strpos( $key, 'form_fill_duration' ) === 0 ) {
$post_data[ $key ] = $val;
} else {
$post_data[ $prefix . '-' . $key ] = $val;
Expand Down
4 changes: 4 additions & 0 deletions projects/plugins/jetpack/changelog/add-forms-submit-timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: enhancement

Forms: Add form fill duration to feedback entries
Loading