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.