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

Add form fill duration to form entries.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -1632,6 +1633,10 @@ 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";
// 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 .= "<input type='hidden' name='" . esc_attr( Feedback::FORM_FILL_DURATION_FIELD ) . "' value='' />\n";
$r .= $form->body;

if ( $is_multistep ) {
Expand Down
94 changes: 82 additions & 12 deletions projects/packages/forms/src/contact-form/class-feedback.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 );

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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.
*
Expand Down
4 changes: 4 additions & 0 deletions projects/packages/forms/src/modules/file-field/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
2 changes: 1 addition & 1 deletion projects/packages/forms/src/modules/form/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
47 changes: 46 additions & 1 deletion projects/packages/forms/src/modules/form/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 ];
Expand Down Expand Up @@ -608,10 +611,38 @@ 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.
*/
trackFirstInteraction: () => {
const context = getContext();

if ( ! context.formFirstInteractionTime ) {
context.formFirstInteractionTime = Date.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.
context.formFirstInteractionTime = null;
// Clear the hidden input too. The timer alone is not enough: the write on submit is
// conditional, so a leftover value from the previous submission could otherwise be
// sent again as if it were a fresh measurement.
const durationField = getForm( context.formHash )?.querySelector(
`input[name="${ FORM_FILL_DURATION_FIELD }"]`
);

if ( durationField ) {
durationField.value = '';
}

// Dispatch custom events to reset all fields
const formElement = document.getElementById( context.elementId );
Expand Down Expand Up @@ -664,6 +695,20 @@ 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.
if ( context.formFirstInteractionTime ) {
const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds.
const durationField = getForm( context.formHash )?.querySelector(
`input[name="${ FORM_FILL_DURATION_FIELD }"]`
);

if ( durationField ) {
durationField.value = duration;
}
}

if ( context.useAjax ) {
event.preventDefault();
event.stopPropagation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,31 @@ 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]" );

$this->assertStringContainsString(
"name='" . Feedback::FORM_FILL_DURATION_FIELD . "'",
$html,
'The rendered form should include the hidden duration input'
);
$this->assertStringContainsString(
"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.
*
Expand Down
Loading
Loading