Skip to content

Forms: Add form fill duration to feedback - #45786

Open
enejb wants to merge 7 commits into
trunkfrom
add/forms-submit-timer
Open

Forms: Add form fill duration to feedback#45786
enejb wants to merge 7 commits into
trunkfrom
add/forms-submit-timer

Conversation

@enejb

@enejb enejb commented Nov 6, 2025

Copy link
Copy Markdown
Member

This PR add a form fill duration to each of the form responses.
We start the timer when the user interacts with the for for the first time.

This is done so that we can track ow long the form take to fill out.

Proposed changes:

  • Add a new feedback field that we store and return the date in seconds.
  • Start recording the data as soon as the user focuses in to the form. And record the time when they submit the form.
  • Store the data on the feedback class.
  • Add the data to the end point so that we can display it to the user when they view it.

Other information:

  • Have you written new tests for your changes, if applicable?
  • Have you checked the E2E test CI results, and verified that your changes do not break them?
  • Have you tested your changes on WordPress.com, if applicable (if so, you'll see a generated comment below with a script to run)?

Jetpack product discussion

See p1762390969490259-slack-C086RGTJT1D

Does this pull request change what data or activity we track or use?

Yes, we track how long it takes the user to submit the form.

Testing instructions:

Create a form.
Fill it out. Test this across browser and different devices. Also test out form fields such as file upload.
Notice that we have the data recorded as expected.

@enejb
enejb requested review from a team and Copilot November 6, 2025 06:06
@enejb enejb added Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Status] Needs Review This PR is ready for review. [Status] Needs Testing We need to add this change to the testing call for this month's release [Package] Forms labels Nov 6, 2025
@github-actions

github-actions Bot commented Nov 6, 2025

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the add/forms-submit-timer branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack add/forms-submit-timer

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions github-actions Bot added [Feature] Forms [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Tests] Includes Tests labels Nov 6, 2025
@github-actions

github-actions Bot commented Nov 6, 2025

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!


Jetpack plugin:

No scheduled milestone found for this plugin.

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds form fill duration tracking to Jetpack forms, measuring the time from a user's first interaction with a form to submission. This feature provides valuable analytics data for form optimization.

  • Tracks form fill duration in seconds from first user interaction to submission
  • Persists duration data in feedback entries and exposes it via REST API
  • Adds frontend JavaScript tracking and backend PHP support

Reviewed Changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
projects/plugins/jetpack/changelog/add-forms-submit-timer Changelog entry for Jetpack plugin
projects/packages/forms/changelog/add-forms-submit-timer Changelog entry for forms package
projects/packages/forms/src/contact-form/class-feedback.php Adds form_fill_duration property and getter method
projects/packages/forms/src/contact-form/class-contact-form.php Adds hidden input field and focusin event handler
projects/packages/forms/src/contact-form/class-contact-form-endpoint.php Adds form_fill_duration to REST API schema and response
projects/packages/forms/src/modules/form/view.js Implements JavaScript tracking logic for form interaction timing
projects/packages/forms/tests/php/contact-form/class-utility.php Updates test utility to support form_fill_duration in test requests
projects/packages/forms/tests/php/contact-form/Feedback_Test.php Adds test for form_fill_duration persistence

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread projects/packages/forms/src/modules/form/view.js Outdated
Comment thread projects/packages/forms/src/contact-form/class-contact-form-endpoint.php Outdated
@jp-launch-control

jp-launch-control Bot commented Nov 6, 2025

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 5 files.

File Coverage Δ% Δ Uncovered
projects/packages/forms/src/modules/form/view.js 0/396 (0.00%) 0.00% 13 💔
projects/packages/forms/src/modules/file-field/view.js 0/164 (0.00%) 0.00% 1 ❤️‍🩹
projects/packages/forms/src/contact-form/class-contact-form-endpoint.php 942/1115 (84.48%) 0.11% 0 💚
projects/packages/forms/src/contact-form/class-contact-form.php 1026/1588 (64.61%) 0.02% 0 💚
projects/packages/forms/src/contact-form/class-feedback.php 809/855 (94.62%) 0.06% 0 💚

Full summary · PHP report · JS report

Coverage check overridden by Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code .

@github-actions

Copy link
Copy Markdown
Contributor

This PR has been marked as stale. This happened because:

  • It has been inactive for the past 3 months.
  • It hasn't been labeled `[Pri] BLOCKER`, `[Pri] High`, `[Status] Keep Open`, etc.

If this PR is still useful, please do a [trunk merge or rebase](https://github.com/Automattic/jetpack/blob/trunk/docs/git-workflow.md#keeping-your-branch-up-to-date) and otherwise make sure it's up to date and has clear testing instructions. You may also want to ping possible reviewers in case they've forgotten about it. Please close this PR if you think it's not valid anymore — if you do, please add a brief explanation.

If the PR is not updated (or at least commented on) in another month, it will be automatically closed.

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"]' );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This storing/calculating in context, then putting the value into input, then submitting the form made me think that we could have these "in memory" type things submitted directly from context as part of submitForm() function, without needing to inject the values first into the DOM.

...but this works now so it's fine :-)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I looked at doing exactly this, but it turns out the DOM round-trip is load-bearing: non-AJAX forms never reach submitForm() at all — they fall through the handler and the browser submits natively from the DOM. So passing the value through submitForm() would only cover the AJAX path, and would reintroduce the bug Copilot flagged just above (the duration silently staying at its default for non-AJAX submissions).

Keeping the hidden input as the single channel means both paths work the same way. I did move the write out of the useAjax branch so it happens for both, and it now reuses the existing getForm() helper from shared.ts instead of re-inlining the lookup.

If we ever drop non-AJAX submissions entirely, your version is definitely the nicer one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh yah, I would think in 2026 non-AJAX doesn't really matter anymore. :-)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I went and checked, and non-AJAX is still reachable through a current product feature rather than just legacy fallback — useAjax is is_response_without_reload_enabled && ! $has_custom_redirect, and has_custom_redirect() is true whenever the form's confirmation type is "redirect" with a customThankyouRedirect URL set. That's the "Redirect to another webpage" option in the form block's confirmation settings (blocks/contact-form/edit.tsx), so any form using it submits natively today.

There's also the plain no-JS case, where the view script never runs at all.

I verified all three paths on a JN site with the current branch:

Path Fill time Recorded
AJAX (default) 7s 7
Non-AJAX (native POST) 12s 12
JavaScript disabled null

The last row is why the hidden input now defaults to empty instead of '0': when the duration is genuinely unknown we store null, so it stays distinguishable from a form that really was filled out in under a second. REST returns {"form_fill_duration": null} for that case, matching the declared array( 'integer', 'null' ) type.

@github-actions

Copy link
Copy Markdown
Contributor

This PR has been marked as stale. This happened because:

  • It has been inactive for the past 3 months.
  • It hasn't been labeled `[Pri] BLOCKER`, `[Pri] High`, `[Status] Keep Open`, etc.

If this PR is still useful, please do a [trunk merge or rebase](https://github.com/Automattic/jetpack/blob/trunk/docs/git-workflow.md#keeping-your-branch-up-to-date) and otherwise make sure it's up to date and has clear testing instructions. You may also want to ping possible reviewers in case they've forgotten about it. Please close this PR if you think it's not valid anymore — if you do, please add a brief explanation.

If the PR is not updated (or at least commented on) in another month, it will be automatically closed.

@github-actions

Copy link
Copy Markdown
Contributor

This PR has been automatically closed as it has not been updated in some time. If you want to resume work on the PR, feel free to restore the branch and reopen the PR.

@github-actions github-actions Bot closed this Apr 17, 2026
@github-actions
github-actions Bot deleted the add/forms-submit-timer branch April 17, 2026 01:06
@enejb
enejb restored the add/forms-submit-timer branch July 29, 2026 21:43
@enejb enejb reopened this Jul 29, 2026
@enejb
enejb force-pushed the add/forms-submit-timer branch from 158412e to 21155af Compare July 29, 2026 21:43
@enejb
enejb force-pushed the add/forms-submit-timer branch from c31aae4 to 4ee4681 Compare July 31, 2026 18:32
@enejb enejb added the Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code label Jul 31, 2026

@kraftbj kraftbj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two things I'd fix before merge:

  1. The timer is never cleared. A second submission in the same page load reports the time since the first fill started. AJAX is on by default and the success panel ships a "Back" link by default, so this is the path most sites get.
  2. absint() is doing validation it can't do. 'abc' stores 0, '-1' stores 1, and '-9223372036854775808' comes back as a float, contradicting the array( 'integer', 'null' ) type this PR adds. 0 deliberately means "filled out in under a second," so junk values land as meaningful.

Three follow-ups I wouldn't block on:

  • Nothing reads the field yet. It's missing from the CSV export (class-contact-form-plugin.php:3007-3009, which does include browser), there's no dashboard consumer, and FormResponse in src/types/index.ts wasn't extended. Until something surfaces it, every failure mode above is invisible. Worth confirming the UI work is tracked. docs/feedback-class.md is missing the new getter too.
  • Drag-and-drop file upload never starts the timer. The dropzone runs in its own jetpack/field-file namespace and a drop fires no focusin. Calling it out because file upload is in your testing instructions.
  • The POST key isn't namespaced. A site owner can set a field's Advanced name/ID to form_fill_duration and quietly win the collision.

The suite passes on the branch (910 tests, 3120 assertions), but all three new tests build $post_data by hand. Delete the hidden input at class-contact-form.php:1615 and every one still goes green. There's also no JS coverage of the timer, though Jest is configured for the package and tests/js/modules/field-phone/view.test.js shows the pattern for testing this kind of store logic. The non-AJAX write-path fix is why the last commit exists, and it's only been verified by hand on a JN site.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Before merge. Set once, never cleared, so it survives a reset.

goBack (line 754) calls form.reset(), which fires reset into onFormReset (line 611). That clears fields, showErrors, and currentStep, but leaves formFirstInteractionTime alone. form.reset() also restores the hidden input to value='', so the next submit re-enters the write block at line 670 and stores Date.now() - <first focus of the first fill>.

Repro: submit with AJAX on, click "Back" on the success panel, fill it in again, submit. The second entry's duration swallows the first fill, the time spent reading the success message, and the "Back" click.

The reload-after-success path starts even earlier. scrollToWrapper calls successWrapper?.focus() while submissionSuccess is true, which trips focusin and starts the clock before anyone touches the form.

One line in onFormReset covers all of it, since goBack already routes through the native reset event:

context.formFirstInteractionTime = null;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in bdad437 and 02be43b.

Confirmed the repro. onFormReset was clearing fields, showErrors and currentStep but not the timer, and goBack routes through data-wp-on--reset="actions.onFormReset", so it was reachable exactly as described.

Verified live on a JN site — fill for 9s, submit, linger 5s on the success panel, go back, refill for 3s:

Entry Fill Recorded
First 9s 9
Second 3s 3

One thing the one-liner didn't cover, which showed up while testing it: form.reset() left the hidden input holding the previous submission's value (I read 10 off it after going back). The write on submit is conditional, so a submit with no interaction recorded would have resent that stale number as if it were a fresh measurement. onFormReset now clears the input as well as the timer, and the same scenario re-run reads "" before the second submit.

return null;
}

return absint( $post_data['form_fill_duration'] );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Before merge. absint() can't validate this. It's abs( (int) $value ) against a hidden input any visitor controls, and the === '' guard above only catches the empty string. What actually gets stored, running each value through on PHP 8.5:

POST value stored
abc 0
-1 1
' ' 0
form_fill_duration[]=1 1
1e10 10000000000
-9223372036854775808 float(9.22e18)

Two separate problems. The design treats 0 as a genuine sub-second fill rather than unknown, so malformed values land as meaningful. And the last row JSON-encodes as 9.223372036854776e+18, which contradicts the array( 'integer', 'null' ) type this PR adds to the endpoint.

The negative case doesn't need an attacker either. Date.now() is wall clock, so a clock step backward mid-fill produces a negative duration in view.js that the server then flips positive.

Validate first, then cast:

$raw = $post_data['form_fill_duration'];

if ( ! is_scalar( $raw ) || ! ctype_digit( (string) $raw ) ) {
	return null;
}

return min( (int) $raw, DAY_IN_SECONDS );

is_scalar has to come first so an array-shaped POST doesn't fatal on the cast. The clamp is optional, but it keeps an abandoned tab from skewing aggregates.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in bdad437. I reproduced your table locally before changing anything — every row behaves as you described, including -9223372036854775808 coming back as a float, which is the one that would have broken the advertised type.

Took the validate-then-cast approach:

$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 );

Kept the clamp — the abandoned-tab case seemed worth guarding given the whole point is aggregates.

The data provider now covers non-numeric, whitespace, negative, scientific notation, decimal and array-shaped input, with separate tests for the overflow and the clamp. A genuine 0 is still preserved as 0, since that is a known duration rather than an unknown one.

registerField( fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra );
},

trackFirstInteraction() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional. focusin catches essentially everything focusable, with one gap: drag-and-drop file upload. fileDropped (src/modules/file-field/view.js:305) runs in the separate jetpack/field-file namespace via data-wp-on--drop, and a drop fires no focus event. Drag a file in, wait, submit, and you get null or a near-zero duration once the submit button takes focus.

File upload is in the testing instructions, so probably worth setting the interaction time in fileDropped the same way.

Lower stakes, separate point: this is the only data-wp-on--* binding for a real DOM event in the package pointing at callbacks.*. Elsewhere callbacks.* is reserved for data-wp-init and data-wp-watch effects. It resolves fine, it just reads oddly next to its neighbors.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Both taken, in bdad437.

trackFirstInteraction moved from callbacks to actions, and the directive is now data-wp-on--focusin="actions.trackFirstInteraction", which puts it in line with its neighbours. That also gave jetpack/field-file a natural way to call it, so fileDropped now starts the timer directly — same jetpackFormStore.actions.* route the file field already uses for updateFieldValue.

Partly verified. I confirmed on JN that the drop path executes and my call raises nothing — dropping a file renders the preview with no console or page errors. I could not complete the end-to-end check, because file uploads fail on that site (File upload failed, try again.), so the form never gets past validation to write a duration. Worth a look on an environment with working uploads.

For what it is worth, the failure mode is safe: if the cross-namespace getContext() were to resolve the field's context rather than the form's, the write lands somewhere harmless and the behaviour is what it is today — the clock starting at the submit button — rather than anything breaking.

// 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='form_fill_duration' value='' />\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional. This name shares a flat POST namespace with author-defined fields.

reservedAttributes in src/blocks/shared/components/jetpack-field-id-control.jsx:7-18 is ten HTML attribute names, so nothing stops a site owner from setting a field's Advanced name/ID to form_fill_duration. Manual IDs are kept verbatim, their field renders after this one, PHP's last-wins hands their value to the metric, and it fails silently.

Unlikely, and only the site owner can trip it. But jetpack_form_fill_duration sidesteps it and matches the jetpack_contact_form_jwt convention on the line above. Would need matching updates at view.js:673 and tests/php/contact-form/class-utility.php:178.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed in bdad437. The field is now jetpack_form_fill_duration, matching jetpack_contact_form_jwt on the line above.

Put the name in Feedback::FORM_FILL_DURATION_FIELD and referenced it from the renderer, the parser and the test utility, so the PHP side can't drift. view.js has a matching constant with a comment pointing back at the PHP one, since that pair is the part a rename could still silently break.

The REST property and the serialized storage key stay form_fill_duration — only the POST field name shares a namespace with author-defined fields, so only that one needed prefixing.

Confirmed on JN that the rendered markup carries the new 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional. These pass, and null-vs-zero is the right thing to pin down.

The gap is that all three build $post_data by hand, so nothing covers the path from rendered markup to stored value. Delete the hidden input at class-contact-form.php:1615 and all three stay green. Contact_Form_Test.php already has the markup-assertion pattern for this.

Two smaller ones, neither blocking:

  • load_from_post() (class-feedback.php:442) is the "entries predating this feature" path the null design exists for, and nothing exercises it. Utility::create_legacy_feedback() is already there for it.
  • Contact_Form_Endpoint_Test::test_item_schema() asserts assertArrayHasKey for every sibling property but wasn't extended for this one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

All three added in bdad437.

  • Markup coverage. Contact_Form_Test::test_rendered_form_carries_form_fill_duration_markup renders the form via do_shortcode and asserts the hidden input, its empty default, and the focusin binding. I checked it actually bites by deleting the input line and re-running — it fails, which was the point.
  • Legacy entries. test_form_fill_duration_is_null_for_legacy_entries goes through Utility::create_legacy_feedback() and asserts null, so the load_from_post() path the whole null design exists for is now exercised.
  • Schema. test_item_schema() now asserts form_fill_duration is present, and that its type allows both integer and null.

The package suite is at 920 tests. The one red is Form_Webhooks_Test::test_send_webhooks_blocks_localhost_hostname, which fails on clean trunk locally too — unrelated to this branch.

@enejb
enejb force-pushed the add/forms-submit-timer branch from 02be43b to 5c10311 Compare August 5, 2026 16:41
@kraftbj
kraftbj self-requested a review August 5, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Feature] Contact Form [Feature] Forms [Package] Forms [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Status] Needs Review This PR is ready for review. [Status] Needs Testing We need to add this change to the testing call for this month's release [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants