diff --git a/projects/packages/forms/changelog/add-forms-submit-timer b/projects/packages/forms/changelog/add-forms-submit-timer
new file mode 100644
index 000000000000..4c0878bfe7b1
--- /dev/null
+++ b/projects/packages/forms/changelog/add-forms-submit-timer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add form fill duration to form entries.
diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php
index 8c1d7f59cf47..8bc2a2ec8c22 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php
@@ -659,6 +659,15 @@ 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. Null when the duration is unknown, such as for submissions predating this feature.', 'jetpack-forms' ),
+ 'type' => array( 'integer', 'null' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ // No sanitize_callback: the field is readonly and sanitized on storage, and
+ // `absint` would coerce a legitimate null into 0.
+ 'readonly' => true,
+ );
+
$schema['properties']['browser'] = array(
'description' => __( 'The browser and platform used to submit the form.', 'jetpack-forms' ),
'type' => 'string',
@@ -991,6 +1000,10 @@ public function prepare_item_for_response( $item, $request ) {
$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();
}
diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php
index 9ab52526846c..1754c22c199d 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form.php
@@ -1544,6 +1544,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=\"actions.trackFirstInteraction\"
data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\"
>\n";
@@ -1632,6 +1633,10 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label
$r .= '';
}
$r .= "\n";
+ // Left empty on purpose: the view script fills this in on submit. An empty
+ // value is stored as null so "never interacted with" stays distinguishable
+ // from "filled out in under a second".
+ $r .= "\n";
$r .= $form->body;
if ( $is_multistep ) {
diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php
index c4bcbaaf281e..c5d30b2a2876 100644
--- a/projects/packages/forms/src/contact-form/class-feedback.php
+++ b/projects/packages/forms/src/contact-form/class-feedback.php
@@ -52,6 +52,19 @@ class Feedback {
*/
public const IS_TEST_META_KEY = '_feedback_is_test';
+ /**
+ * Name of the hidden POST field carrying the form fill duration.
+ *
+ * Prefixed because submitted fields share one flat POST namespace with author-defined
+ * fields, whose names a site owner can set by hand. An unprefixed `form_fill_duration`
+ * field would silently overwrite this one.
+ *
+ * @since $$next-version$$
+ *
+ * @var string
+ */
+ public const FORM_FILL_DURATION_FIELD = 'jetpack_form_fill_duration';
+
/**
* Cache key for the source post IDs list.
*
@@ -248,6 +261,15 @@ public static function maybe_backfill_source_meta( $post_id, $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.
*
@@ -427,10 +449,11 @@ private function load_from_post( WP_Post $feedback_post ) {
! empty( $parsed_content['is_test'] )
);
- $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();
$this->logged_in_user = $parsed_content['logged_in_user'] ?? null;
@@ -493,14 +516,15 @@ private function load_from_submission( $post_data, $form, $current_post = null,
$this->form_id = $form_id_attribute > 0 ? $form_id_attribute : null;
// 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 = $this->get_computed_form_fill_duration( $post_data );
+ $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 );
@@ -1124,6 +1148,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.
*
@@ -1567,6 +1602,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,
'logged_in_user' => $this->logged_in_user,
),
@@ -2254,6 +2290,40 @@ private function get_computed_consent( $post_data, $form ) {
return false;
}
+ /**
+ * Gets the computed form fill duration, in seconds.
+ *
+ * The value is supplied by the view script as a hidden field, so it is submitter-controlled
+ * and cannot be trusted. Anything that is not a plain sequence of digits is treated as
+ * unknown and stored as null, rather than being coerced into a number that would read as a
+ * real measurement: `absint()` alone would turn "abc" into 0 (indistinguishable from a
+ * genuine sub-second fill), "-1" into 1, and a value past PHP_INT_MAX into a float, which
+ * would contradict the integer type the REST schema advertises.
+ *
+ * The value is left empty when the submitter never interacted with the form, or ran without
+ * JavaScript, which is also an unknown duration.
+ *
+ * @since $$next-version$$
+ *
+ * @param array $post_data The post data from the form submission.
+ * @return int|null
+ */
+ private function get_computed_form_fill_duration( $post_data ) {
+ if ( ! isset( $post_data[ self::FORM_FILL_DURATION_FIELD ] ) ) {
+ return null;
+ }
+
+ $raw = $post_data[ self::FORM_FILL_DURATION_FIELD ];
+
+ // is_scalar() has to come first so an array-shaped POST does not blow up on the cast.
+ if ( ! is_scalar( $raw ) || ! ctype_digit( (string) $raw ) ) {
+ return null;
+ }
+
+ // Clamp so an abandoned tab left open for days cannot skew aggregates.
+ return min( (int) $raw, DAY_IN_SECONDS );
+ }
+
/**
* Gets the computed notification recipients.
*
diff --git a/projects/packages/forms/src/modules/file-field/view.js b/projects/packages/forms/src/modules/file-field/view.js
index a0307367a22d..6632f7c76907 100644
--- a/projects/packages/forms/src/modules/file-field/view.js
+++ b/projects/packages/forms/src/modules/file-field/view.js
@@ -304,6 +304,10 @@ const { state, actions } = store( NAMESPACE, {
*/
fileDropped: event => {
event.preventDefault();
+ // A drop fires no focus event, so the form's `focusin` handler never sees it.
+ // Without this, dropping a file and submitting would report the fill as starting
+ // at the submit button rather than at the drop.
+ jetpackFormStore.actions.trackFirstInteraction();
if ( event.dataTransfer ) {
for ( const item of Array.from( event.dataTransfer.items ) ) {
if ( item.webkitGetAsEntry()?.isDirectory ) {
diff --git a/projects/packages/forms/src/modules/form/shared.ts b/projects/packages/forms/src/modules/form/shared.ts
index d93204cf6858..88e17936ef9f 100644
--- a/projects/packages/forms/src/modules/form/shared.ts
+++ b/projects/packages/forms/src/modules/form/shared.ts
@@ -9,7 +9,7 @@ const debug = debugFactory( 'jetpack-forms:interactivity' );
const NAMESPACE = 'jetpack/form';
const config = getConfig( NAMESPACE );
-const getForm = ( formHash: string ) => {
+export const getForm = ( formHash: string ) => {
return document.getElementById( 'jp-form-' + formHash ) as HTMLFormElement | null;
};
diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js
index 8a5aa3b359f7..c1594c09015a 100644
--- a/projects/packages/forms/src/modules/form/view.js
+++ b/projects/packages/forms/src/modules/form/view.js
@@ -14,7 +14,7 @@ import {
import { validateField, isEmptyValue } from '../../contact-form/js/validate-helper.js';
import { getRating } from '../field-rating/view.js';
import { maybeAddColonToLabel, maybeTransformValue, getImages, getUrl } from './helpers.js';
-import { focusNextInput, submitForm } from './shared.ts';
+import { focusNextInput, getForm, submitForm } from './shared.ts';
// Import field type icons view to register its callbacks.
import './field-type-icons-view.js';
@@ -28,6 +28,9 @@ const NAMESPACE = 'jetpack/form';
const config = getConfig( NAMESPACE );
let errorTimeout = null;
+// Must match Feedback::FORM_FILL_DURATION_FIELD in src/contact-form/class-feedback.php.
+const FORM_FILL_DURATION_FIELD = 'jetpack_form_fill_duration';
+
const updateField = ( fieldId, value, showFieldError = false, validatorCallback = null ) => {
const context = getContext();
let field = context.fields[ fieldId ];
@@ -608,10 +611,33 @@ const { state, actions } = store( NAMESPACE, {
actions.updateField( context.fieldId, event.target.value, true );
},
+ /**
+ * Start the fill timer on the submitter's first interaction with the form.
+ *
+ * Bound to `focusin` on the form wrapper, which covers every focusable control. File
+ * drag-and-drop fires no focus event, so `jetpack/field-file` calls this directly.
+ *
+ * Uses `performance.now()` rather than `Date.now()`: it is monotonic, so a wall-clock
+ * step backward mid-fill cannot produce a negative duration. Compared against null
+ * rather than tested for truthiness, since a legitimate reading can be 0.
+ */
+ trackFirstInteraction: () => {
+ const context = getContext();
+
+ if ( context.formFirstInteractionTime == null ) {
+ context.formFirstInteractionTime = performance.now();
+ }
+ },
+
onFormReset: () => {
const context = getContext();
context.fields = [];
context.showErrors = false;
+ // Start the fill timer over. Without this, going back from the success panel and
+ // filling the form in again would report a duration that also covers the first
+ // submission and the time spent reading the confirmation. The hidden input needs no
+ // clearing here because the write on submit is unconditional.
+ context.formFirstInteractionTime = null;
// Dispatch custom events to reset all fields
const formElement = document.getElementById( context.elementId );
@@ -664,6 +690,24 @@ const { state, actions } = store( NAMESPACE, {
context.isSubmitting = true;
+ // Record the fill duration in the DOM before submitting. This has to happen outside
+ // the `useAjax` branch below: non-AJAX forms submit natively, so the value is only
+ // sent if it is already on the hidden input by this point.
+ //
+ // The write is unconditional so the input can never carry a value left over from an
+ // earlier fill — an unknown duration explicitly writes an empty string, which the
+ // server stores as null.
+ const durationField = getForm( context.formHash )?.querySelector(
+ `input[name="${ FORM_FILL_DURATION_FIELD }"]`
+ );
+
+ if ( durationField ) {
+ durationField.value =
+ context.formFirstInteractionTime == null
+ ? ''
+ : Math.round( ( performance.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds.
+ }
+
if ( context.useAjax ) {
event.preventDefault();
event.stopPropagation();
diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php
index 3802e737b2e7..1c288a0c24b8 100644
--- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php
+++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php
@@ -179,6 +179,11 @@ public function test_item_schema() {
$this->assertArrayHasKey( 'is_test', $schema_properties );
$this->assertEquals( 'boolean', $schema_properties['is_test']['type'] );
$this->assertArrayHasKey( 'preview_url', $schema_properties );
+ $this->assertArrayHasKey( 'form_fill_duration', $schema_properties );
+
+ // The duration is null whenever it is unknown, so the schema has to allow both.
+ $this->assertContains( 'integer', $schema_properties['form_fill_duration']['type'] );
+ $this->assertContains( 'null', $schema_properties['form_fill_duration']['type'] );
// Verify logged_in_user schema structure
$logged_in_user_schema = $schema_properties['logged_in_user'];
diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php
index 735a9368031a..65e400160147 100644
--- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php
+++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php
@@ -1306,6 +1306,29 @@ public function test_token_with_curly_brackets_can_be_replaced() {
$this->assertEquals( 'Chicago', $plugin->replace_tokens_with_input( $subject, $field_values ) );
}
+ /**
+ * The rendered form must carry the hidden duration input and the focusin binding that
+ * populates it. The storage-level tests build their POST data by hand, so without this
+ * the whole feature could be removed from the markup and they would all still pass.
+ */
+ public function test_rendered_form_carries_form_fill_duration_markup() {
+ $html = do_shortcode( "[contact-form][contact-field label='Name' type='name' required='1'/][/contact-form]" );
+
+ // Asserted as a pair: an unanchored `value=''` would also match the Name field, which
+ // renders an empty value of its own, so it would pass even if the duration input
+ // carried a default.
+ $this->assertStringContainsString(
+ "name='" . Feedback::FORM_FILL_DURATION_FIELD . "' value=''",
+ $html,
+ 'The duration input should render empty so an unrecorded duration stores as null'
+ );
+ $this->assertStringContainsString(
+ 'data-wp-on--focusin="actions.trackFirstInteraction"',
+ $html,
+ 'The form wrapper should bind focusin to start the fill timer'
+ );
+ }
+
/**
* Tests that the field attributes remain the same when no escaping is necessary.
*
diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php
index 9c2d8f97e6c5..9194836dac85 100644
--- a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php
+++ b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php
@@ -12,6 +12,7 @@
require_once __DIR__ . '/class-utility.php';
use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
use WorDBless\BaseTestCase;
/**
@@ -383,4 +384,218 @@ public function test_author_first_last_on_submission() {
$this->assertSame( 'Jane', $response->get_author_first_name(), 'First name getter should return raw first name' );
$this->assertSame( 'Doe', $response->get_author_last_name(), 'Last name getter should return raw last name' );
}
+
+ /**
+ * 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' => 'john@example.com',
+ 'message' => 'Test message',
+ Feedback::FORM_FILL_DURATION_FIELD => '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' );
+ }
+
+ /**
+ * An empty form_fill_duration means the duration is unknown, and must be stored as null
+ * rather than as 0 so it stays distinct from a form that really was filled out instantly.
+ *
+ * @dataProvider provide_unknown_form_fill_durations
+ *
+ * @param array $extra_post_data Submission data to merge in.
+ * @param string $message Assertion message.
+ */
+ #[DataProvider( 'provide_unknown_form_fill_durations' )]
+ public function test_form_fill_duration_is_null_when_unknown( $extra_post_data, $message ) {
+ $form_id = Utility::get_form_id();
+
+ $_post_data = Utility::get_post_request(
+ array_merge(
+ array(
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ 'message' => 'Test message',
+ ),
+ $extra_post_data
+ ),
+ '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'/]"
+ );
+
+ $response = Feedback::from_submission( $_post_data, $form );
+ $saved_response = Feedback::get( $response->save() );
+
+ $this->assertNull( $response->get_form_fill_duration(), $message );
+ $this->assertNull( $saved_response->get_form_fill_duration(), $message . ' (after save/load)' );
+ }
+
+ /**
+ * Data provider for test_form_fill_duration_is_null_when_unknown.
+ *
+ * @return array
+ */
+ public static function provide_unknown_form_fill_durations() {
+ return array(
+ 'empty value (no interaction recorded)' => array(
+ array( Feedback::FORM_FILL_DURATION_FIELD => '' ),
+ 'An empty duration should be stored as null, not 0',
+ ),
+ 'field absent entirely' => array(
+ array(),
+ 'A missing duration should be stored as null',
+ ),
+ 'non-numeric text' => array(
+ array( Feedback::FORM_FILL_DURATION_FIELD => 'abc' ),
+ 'Non-numeric input should be null, not absint()-ed to 0',
+ ),
+ 'whitespace only' => array(
+ array( Feedback::FORM_FILL_DURATION_FIELD => ' ' ),
+ 'Whitespace should be null, not 0',
+ ),
+ 'negative value' => array(
+ array( Feedback::FORM_FILL_DURATION_FIELD => '-1' ),
+ 'A negative duration is not meaningful and should be null, not flipped positive',
+ ),
+ 'scientific notation' => array(
+ array( Feedback::FORM_FILL_DURATION_FIELD => '1e10' ),
+ 'Scientific notation should be null rather than expanded to 10000000000',
+ ),
+ 'decimal' => array(
+ array( Feedback::FORM_FILL_DURATION_FIELD => '1.5' ),
+ 'A non-integer duration should be null',
+ ),
+ 'array-shaped POST' => array(
+ array( Feedback::FORM_FILL_DURATION_FIELD => array( '1' ) ),
+ 'An array-shaped value should be null and must not fatal on the cast',
+ ),
+ );
+ }
+
+ /**
+ * Entries created before this feature existed have no duration recorded at all. This is the
+ * case the null design exists for, and it goes through load_from_post() rather than
+ * from_submission().
+ */
+ public function test_form_fill_duration_is_null_for_legacy_entries() {
+ $feedback_id = Utility::create_legacy_feedback();
+ $response = Feedback::get( $feedback_id );
+
+ $this->assertNull( $response->get_form_fill_duration(), 'An entry predating this feature should report a null duration' );
+ }
+
+ /**
+ * A value past PHP_INT_MAX must not become a float — the REST schema advertises
+ * array( 'integer', 'null' ), and a float would JSON-encode as 9.223372036854776e+18.
+ */
+ public function test_form_fill_duration_rejects_overflow() {
+ $saved_response = $this->save_submission_with_duration( '-9223372036854775808' );
+
+ $this->assertNull( $saved_response->get_form_fill_duration(), 'An overflowing value should be null rather than a float' );
+ }
+
+ /**
+ * An implausibly long duration is clamped so a tab left open for days cannot skew aggregates.
+ */
+ public function test_form_fill_duration_is_clamped() {
+ $saved_response = $this->save_submission_with_duration( (string) ( DAY_IN_SECONDS * 30 ) );
+
+ $this->assertSame( DAY_IN_SECONDS, $saved_response->get_form_fill_duration(), 'A duration beyond a day should be clamped to DAY_IN_SECONDS' );
+ $this->assertIsInt( $saved_response->get_form_fill_duration(), 'The clamped duration should still be an integer' );
+ }
+
+ /**
+ * Saves a submission carrying the given raw duration value and returns the reloaded entry.
+ *
+ * @param mixed $raw_duration The raw POST value for the duration field.
+ * @return Feedback
+ */
+ private function save_submission_with_duration( $raw_duration ) {
+ $form_id = Utility::get_form_id();
+
+ $_post_data = Utility::get_post_request(
+ array(
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ 'message' => 'Test message',
+ Feedback::FORM_FILL_DURATION_FIELD => $raw_duration,
+ ),
+ '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'/]"
+ );
+
+ $response = Feedback::from_submission( $_post_data, $form );
+
+ return Feedback::get( $response->save() );
+ }
+
+ /**
+ * A real, sub-second fill still records 0 — that is a known duration, not an unknown one.
+ */
+ public function test_form_fill_duration_zero_is_preserved() {
+ $form_id = Utility::get_form_id();
+
+ $_post_data = Utility::get_post_request(
+ array(
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ 'message' => 'Test message',
+ Feedback::FORM_FILL_DURATION_FIELD => '0',
+ ),
+ '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'/]"
+ );
+
+ $response = Feedback::from_submission( $_post_data, $form );
+ $saved_response = Feedback::get( $response->save() );
+
+ $this->assertSame( 0, $saved_response->get_form_fill_duration(), 'An explicit 0 duration should be preserved as 0, not converted to null' );
+ }
}
diff --git a/projects/packages/forms/tests/php/contact-form/class-utility.php b/projects/packages/forms/tests/php/contact-form/class-utility.php
index d5640d7d8a65..0d6404abac00 100644
--- a/projects/packages/forms/tests/php/contact-form/class-utility.php
+++ b/projects/packages/forms/tests/php/contact-form/class-utility.php
@@ -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 || $key === Feedback::FORM_FILL_DURATION_FIELD ) {
$post_data[ $key ] = $val;
} else {
$post_data[ $prefix . '-' . $key ] = $val;
diff --git a/projects/plugins/jetpack/changelog/add-forms-submit-timer b/projects/plugins/jetpack/changelog/add-forms-submit-timer
new file mode 100644
index 000000000000..67502098dd6c
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-forms-submit-timer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Forms: Add form fill duration to feedback entries.