Sync master from upstream - #123
Merged
Merged
Conversation
Ports the work from #2975 onto next-release. That branch was 288 commits behind dev and could not be updated: merging dev in is rejected by the org required_signatures ruleset, because the merge introduces a6346487a (an unsigned commit from 7 July) into the branch. It predates the rule and already sits on dev, next-release and master, but the rule will not let it enter a branch that does not have it — the same rejection comes from git push, gh pr update-branch and the REST update-branch endpoint, and re-signing it would rewrite 220 commits across three protected branches. Squashes the five original signed commits; the net diff is identical to #2975 at 13 files, +849/-37. - inc/form-views.php: Form_Views, the public sureforms/v1/forms/track-view endpoint (HMAC Submit_Token rather than a nonce, so it survives page caching), exclusion re-checks, a fail-closed per-IP+form rate limit, and an atomic $wpdb increment of _srfm_form_views. - assets/js/unminified/form-submit.js: IntersectionObserver beacon, once per form, so views count on fully cached pages. - inc/forms-data.php: views and conversion_rate in prepare_form_for_listing(), plus the compute-sort-paginate path for sorting on a derived metric. - src/admin/forms/components/FormsTable.js: the two sortable columns. - General settings toggle for the feature; _srfm_form_views registered in post-types.php; orderby enum extended in rest-api.php. - tests/unit/inc/test-form-views.php. Verified on this branch by exit code: PHPCS 0, PHPStan level 9 0, phpinsights --min-quality=100 --min-architecture=100 --min-style=100 0, ESLint 0, and 11 Form_Views tests (26 assertions) pass. Manual smoke test and a review of the unauthenticated track-view endpoint are still outstanding — noted in the PR description.
The General-settings toggle only gated the beacon and the REST endpoint, so switching tracking off left both columns on the Forms table. With no new views being recorded they would sit frozen at whatever had been counted before, which reads as broken data rather than a disabled feature. - admin/admin.php: expose the same setting to the admin bundle as srfm_admin.form_views_tracking, resolved through Form_Views::is_tracking_enabled() so there is one source of truth rather than a second copy of the option read. - FormsTable.js: spread the two column definitions in only when the flag is not false. Absent flag means shown, matching the server-side default for installs that predate the setting. - inc/forms-data.php: stop computing the metrics when tracking is off. Hiding the columns alone would have left a per-form meta read on every listing request for values nothing renders, and would still have honoured orderby=views / conversion_rate — running the compute-sort-paginate pass to order rows nobody can see. That request now falls through to the default ordering. Verified against the real option rather than by inspection: is_tracking_enabled() returns true / false / true for on, off and key-absent, and prepare_form_for_listing() returns views=42 conversion_rate=2.4 with tracking on and views=0 conversion_rate=0.0 with it off, for the same seeded meta. PHPCS 0, PHPStan level 9 0, phpinsights --min-quality=100 0, ESLint 0, build:script 0, 19 tests (46 assertions) pass.
… as a string
The previous commit did not work. WP_Scripts::localize() casts every scalar in
the payload with (string) (wp-includes/class-wp-scripts.php), so the PHP boolean
false reached JS as an empty string and the guard
false === window.srfm_admin?.form_views_tracking
could never match. The columns stayed visible with tracking off.
My error was verifying one side and assuming the boundary: I checked
is_tracking_enabled() and prepare_form_for_listing() against the real option and
reported it working, without ever reading the value that actually arrives in the
browser.
The contract is now explicit on both sides. PHP sends '1'/'0' rather than a
boolean, with the reason recorded at the call site so nobody 'tidies' it back,
and the JS compares as a string, defaulting a missing flag to '1' so the columns
stay visible if the PHP predates the setting.
Verified in the browser on both states this time, reading the rendered table
headers rather than the source:
tracking OFF -> flag '0' (typeof string) -> headers: Title, Shortcode,
Entries, Date & Time, Actions
tracking ON -> flag '1' -> headers: ... Entries, Views,
Conversion Rate, Date & Time, Actions
PHPCS 0, PHPStan 0, phpinsights --min-quality=100 0, ESLint 0, build 0.
Conversion rate divided all-time entries by views that only began accruing when this feature shipped, so any form that already existed reported a rate built from two different periods. The min( 100, ... ) clamp hid the absurdity rather than the error: a form with 500 entries and 3 views displayed a confident 100%, and since the column sorts, those forms sorted above genuinely good ones. Entries are now counted from the same moment as views. - Form_Views::TRACKING_STARTED_OPTION records when counting opened. Written once, on first use, and never rewritten — tracking is on by default so there is no toggle event to hang it on, and add_option() means a concurrent request cannot move a window that is already open. Kept in its own option rather than inside srfm_general_settings_options, because that array is rebuilt from an allowlist on save and an unrecognised key would be silently dropped. - Forms_Data counts entries with created_at >= the window, reusing the where_clause the entries table already accepts and the (form_id, created_at, status) index. A form created after the window opened measures from its own creation instead, so its first days are not diluted by a window it did not exist for. - conversion_rate is now nullable. More entries than views is impossible, so rather than clamping, the value is null and the table shows the dash it already uses for 'no data yet'. The Entries column is untouched and stays all-time. Not re-stamped when tracking is switched off and on: the earlier view counts are kept, and moving the start forward would measure them against a shorter entry window. Entries arriving while tracking is off skew the rate slightly, which the entries-exceed-views guard covers when it becomes meaningless. Verified against real data rather than by inspection. The timestamp is stable across repeated calls and not autoloaded. With the window opened now, a legacy form's older entry is excluded and the rate reads 0.0 instead of the previous 100.0; with the window backdated two years the same entry is counted and the rate reads 25.0 against 4 views; zero views yields null. PHPCS 0, PHPStan level 9 0, phpinsights --min-quality=100 0, ESLint 0, build 0, 19 tests (46 assertions).
The General-settings toggle now governs display only. should_track() no longer consults it, so the beacon and REST endpoint keep counting while the Views and Conversion Rate columns are hidden — switching them back on reveals the period rather than a gap. The server-side gates in forms-data.php (payload + orderby) stay, since they are what hides the data. Settings copy and the is_tracking_enabled() docblock rewritten to describe display-only semantics, and the test_is_tracking_enabled assertion inverted: it now fails if the toggle check is ever put back into should_track().
Closes the check-test-coverage gap on the conversion-rate window stamp. Pins the three invariants the rate depends on: the stamp is written once and never drifts, an existing stamp is returned verbatim, and the option is not autoloaded. Verified by revert — flipping autoload to true fails the third assertion, dropping the stored-value early return fails the second. Also drops the "switched off and on again" paragraph from the docblock: counting no longer stops, so there is no gap for entries to skew.
The placeholder sat inside a translatable string, and Gruntfile's plugin_function_comment task rewrites a bare /x\.x\.x/gi across src/**/*.js at release. That changes the msgid on every version bump, so every translation of this string would be lost each release. No other user-facing string in src/ does this — the remaining placeholders are all @SInCE docblocks. "when this feature first runs on your site" is also the more accurate statement: get_tracking_started_at() stamps the window on first use, which on a site that upgrades late is not the release date.
…icks The pill was absolutely positioned at the container's top-right, which only clears the fields when the container happens to have enough top padding. With default styling it landed on top of the first row's last field. It now renders in normal flow on its own right-aligned row immediately above the <form>, so it cannot overlap anything at any width with any theme. The form shifts down by the pill's height, but only for users who can edit it — the markup is still absent from the DOM for everyone else. Analytics: the edit link carries an `srfm_edit_src=embed` marker that Admin::maybe_track_edit_form_button_click() reads on load-post.php and counts into `edit_form_button_clicks`, emitted as the cumulative `edit_form_button_clicked` event. Attributing via the URL keeps the front end script-free and adds no AJAX endpoint, and measures the editor actually opening rather than a click that may not land. Every decision is taken from server state: the post type comes from the resolved post and the capability from `edit_post` on that form, so an absent, misspelled or reused marker, a non-form post, and a user without the capability all fall through to no-op. Also fixes a latent fatal in the same function: the Elementor guard checked only `class_exists( '\Elementor\Plugin' )`, but Elementor declares `public static $instance = null` and populates it on boot, so `null->editor->is_edit_mode()` was reachable. That is a fatal Error, not a warning, and it was already breaking two tests in this class on dev.
get_edit_form_button_css() changed body without a matching test, which check-test-coverage flags. It also deserves one on its own terms: DOM order alone does not stop the overlap if the CSS lifts the pill back out of flow. Asserts no absolute positioning, no leftover container positioning context, the flow wrapper, and logical spacing. Revert-tested — restoring `position: absolute`, restoring the container rule, and swapping margin-block-end for margin-bottom each fail their own assertion.
Three signals, so the warehouse can tell whether the feature is actually being used rather than just shipped: - boolean_values.form_views_columns_enabled — whether the Forms list shows the columns. Delegated to Form_Views::is_tracking_enabled() rather than read from the option array, because the setting defaults to ON when the key has never been saved: `! empty()` on the raw array would report every pre-upgrade install as opted out. - numeric_values.total_form_views — views summed across published forms. Drafts and trashed forms are excluded so the numerator cannot outrun a denominator that no longer counts them. - numeric_values.forms_with_views — published forms viewed at least once, which separates "the beacon is firing" from "forms exist but are never embedded". A total alone cannot: one busy form looks like many quiet ones. The sum is a direct query (no core alternative for SUM over postmeta), cached for an hour under the existing sureforms cache group and following the annotation style of most_used_anti_spam(). Revert-tested: including drafts in the sum, relaxing the views filter to >= 0, and reading the toggle raw instead of delegating each fail their own assertion.
The feature now ships off. Counting begins the first time an administrator switches it on, and from then on the toggle only shows or hides the columns. - is_tracking_enabled() defaults to false. Deny is the fallthrough: absent option, absent key and a corrupted non-array value all return false. The other six default sites (global settings, the abilities getter, the React settings store, the column gate) flip with it. - The stamp is written by maybe_start_tracking(), hooked to the general settings option write rather than to a single handler, so it fires for the settings screen, the abilities endpoint and WP-CLI alike. add_option() keeps it write-once, so an off/on cycle cannot move an open window. Both update_option_* and add_option_* are hooked, because the former does not fire when the row does not exist yet — the fresh-install case this exists for. The new value is read from the second parameter, which is where both hooks put it despite their first parameters differing. - get_tracking_started_at() is now a pure read returning 0 when never enabled. The old lazy write opened a window on any read, including from a site that never enabled the feature. - should_track() gates on the stamp, not the toggle: nothing is counted before the first opt-in, and counting continues once the columns are hidden again. - forms-data.php additionally guards on a non-zero stamp, because the toggle and the stamp are written by different paths and gmdate() on a zero timestamp would widen the window to 1970 and count every entry ever. Dropped the clamp to post_date_gmt: a form cannot have entries from before it existed, so it never changed a result. Revert-tested: defaulting back to ON, dropping the stamp gate in should_track(), re-stamping with update_option(), stamping while the toggle is off, and reading the first hook argument instead of the second each fail their own assertion. The write-once assertion is backdated deliberately — comparing against a stamp written moments earlier passed even with update_option(), since both writes land in the same second.
Closes the check-test-coverage gap on the new stamp writer. The other tests reach it through update_option(), which only exercises one of the two hook shapes; this calls it directly with both. Pins the contract that makes both hooks work — the new value is the second parameter in each, despite their first parameters differing — plus write-once and the deny-by-default cases (toggle off, key absent, empty array, non-array, null). Revert-tested: reading the first argument, swapping add_option for update_option, and stamping regardless of the toggle each fail their own assertion.
Sort/display mismatch (the worst of them): the Conversion Rate column sorted on all-time entries while it displayed entries from the tracking window, so a form rendering a dash sorted as several hundred percent and landed at the top of a descending sort. Both paths now go through one calculate_form_metrics(), with unmeasurable rates sorting last instead of tying with a real 0%. Beacon hardening: - The rate limiter was a read-modify-write over a transient, so concurrent requests all read the same value and the limit only ever bound sequential traffic — on the only control between an anonymous caller and an unbounded write loop. Now an atomic wp_cache_add/incr where a persistent cache exists. - Buckets key on the network (/24, /64), not the exact address, and a per-form site-wide ceiling is checked first. Without both, an attacker with an IPv6 /64 got a fresh allowance per address and could mint unbounded wp_options rows, since the first hit on a new bucket is always free. - The beacon signs its own 'srfm_view' namespace instead of reusing the submission token, so a token scraped from public markup cannot be replayed against the submit endpoint and this route is not an oracle for whether a submit token is still in an accepted window. NAMESPACE_SUBMIT is the historical literal, so tokens in cached HTML keep verifying. - Views no longer accrue for draft or trashed forms during the token window. increment_views(): wp_postmeta has no unique index, so add_post_meta()'s $unique is a SELECT then an INSERT and two concurrent first-views could create two rows that both increment forever — the comment claiming otherwise was simply wrong. Re-runs the UPDATE when the insert loses. Also CASTs meta_value so a non-numeric value does not error under STRICT_TRANS_TABLES and stop that form counting permanently, and distinguishes a failed query from a missing row. Correctness and UX: - Duplicating a form no longer clones its view count; the copy started life with impressions it never had and a permanent 0% rate. - is_tracking_enabled() self-heals the stamp, so a settings import or restore cannot leave the columns visible while nothing ever counts. - The beacon flag is localized as '1'/'0', matching the convention admin.php sets. A raw bool worked only by accident of (string) false === ''. - The column gate reads a flag returned with the rows instead of a page-load snapshot, so toggling in another tab cannot render columns over empty data. A stale ?orderby=views resets rather than silently date-sorting. - The metric sort is bounded and announces the skip; the listing primes the post cache and skips a redundant entry COUNT; the window boundary is resolved against MySQL's clock once per request rather than per row. - Dropped the analytics cache, which no caller could ever read twice. Swept six comments that still described the pre-opt-in default, including two that shipped to wordpress.org stating the opposite of the code. Tests: calculate_form_metrics, prepare_form_for_listing, window_boundary_sql, network_bucket, hit_counter, remaining_window, the token namespace split, and the duplicate-form skip. The duplicate-form test initially passed for the wrong reason — the helper returns a response array, not an ID — and only the revert-test exposed it.
Master to Dev 2.12.5
Dev to Next Release 2.12.5
The beacon called window.wp.apiFetch, which srfm-form-submit no longer loads, so it threw a synchronous TypeError and the feature counted nothing on every install. Switched to plain fetch() against a resolved REST URL, matching the two submission calls in the same file. Also: - Resolve the privileged-user exclusion through the auth cookie, since REST resets the current user to 0 on nonce-less requests. - Dedupe views on form id rather than DOM element, and share one IntersectionObserver instead of one per form per re-init. - Charge the caller's own rate-limit bucket before the shared per-form ceiling, and fail closed when the object cache is unavailable. - Make the localized beacon flag cache-safe, and pass the same metric arguments on the sort path as on the render path. - Collapse duplicate view-counter rows left by a racing first view.
- Reject a non-scalar `srfm_edit_src` before sanitize_key(). isset() is satisfied by `?srfm_edit_src[]=x` and sanitize_key() only grew its is_scalar() guard after this plugin's minimum WordPress, so that input reached strtolower( array ) — the one shape that fatalled instead of no-opping. - Dedup the click counter per editor per form per hour, so a refresh or a back-navigation no longer counts again. - Register the marker in removable_query_args so it leaves the admin URL once counted, keeping it out of bookmarks and the Referer header. - Extend the Elementor null guard to ->editor, which has the same null window between plugins_loaded and init that the guard was added for. - Build the edit link from the raw URL rather than the display-escaped one. - Drop a condition that the enclosing branch already guarantees. - Correct the capability comment: edit_post on this CPT resolves to manage_options, with no per-post component. - Tests: assert the full editor href rather than a bare `post=<id>`, cover the empty/non-scalar/deleted-post rejection paths, and add a tearDown so one failure no longer cascades.
…styling The Getting Started nudge and the "Finish setting up" prompt were registered independently, so a user with a freshly imported starter template saw both stacked. They compete for the same next action, so the specific one now wins: "finish this form" is a concrete step, "explore the dashboard" is a tour. The decision moved into get_displayable_thankyou_prompt(), which both the renderer and the suppressing notices read, rather than duplicating the conditions. It costs nothing extra — the underlying query is already memoized per request. The rating notice yields to the prompt for the same reason: a user with three forms who then imports a template would otherwise hit the identical collision through the other path. Styling is now one system. All three notices render through build_srfm_notice_markup() and are painted by one stylesheet scoped to .srfm-notice, so the Getting Started and rating notices pick up the brand accent, the compact mark, and the borderless secondary links the prompt already used. The old builder had no callers left and is gone (-59 lines).
When `{$wpdb->prefix}srfm_entries` goes missing — a host migration, a
partial restore, a changed `$table_prefix`, a manual drop — nothing
recreates it and every submission silently fails to save.
`srfm_database_table_versions` still records the current version, so
`start_db_upgrade()` marks the table non-upgradable and `create()`
early-returns. There was no existence check anywhere in the plugin and
nothing told the site owner.
Adds detection, a self-service repair on two surfaces, and analytics.
Detection (`Base::table_exists()`) uses `SHOW TABLES LIKE` rather than
`get_columns()`, because `SHOW COLUMNS FROM <missing>` is a MySQL error
that cannot distinguish "missing" from "denied". `esc_like()` matters:
`$wpdb->prefix` contains `_`, a LIKE wildcard. A DB-level error reports
the table as present, so a broken connection never raises a false alarm.
Repair prefers adopting the site's own data over manufacturing an empty
table. A changed prefix leaves the rows behind under the old name, and
creating a fresh table there would strand every stored entry while
looking like a successful repair. `find_adoptable_table()` refuses to
guess: exactly one candidate, carrying every column the schema declares,
never another blog's table on multisite, never a suffixed backup. It
RENAMEs rather than copies, so the move is atomic and cannot leave rows
in two places.
Repair reports a fresh existence check, never `create()`'s return value.
Reporting success on `create()` alone could record the version again
while the table stayed missing, hiding the problem permanently.
The copy is deliberately routine maintenance, not an error, and branches
on what the repair will actually do — whether existing entries come back
with it or the table starts empty. Promising the wrong one is how a
maintenance prompt turns into a complaint.
Also guards the analytics payload, which queried the entries table
through two sinks and raised a DB error per send on exactly the sites
whose breakage we most need reported.
Analytics: `database_error_notice_shown` (throttled to once per user per
day, so a site left unfixed cannot dominate the aggregate),
`database_error_notice_cta`, `database_repair_result`, and a
`db_entries_table_missing` daily KPI.
…g entry points check-test-coverage matches on an exact test_<function_name> prefix, so the behaviour-named tests did not register against it. Renames the primary test for each new function and adds real coverage for the entry points that had none: register_database_repair_notice() (both the missing and healthy paths), handle_database_repair() (capability check ahead of the nonce and any write), has_expected_columns(), get_tablename(), create() and get_db_tables(). Also covers register_pro_compatibility_notices(), which the gate attributes to this diff because the new methods were inserted after it.
…g the notice The Fix now button reloaded the page bare, so PHP had nothing to key the confirmation off and the warning simply vanished — indistinguishable from a failure to anyone watching. The comment claiming the reloaded page carried the success notice was wrong. Sets srfm_db_repair=done on the reload, the same arg the admin-post fallback already redirects with, so both surfaces land identically. Caught by clicking it: the notice disappeared with nothing in its place.
…a file Debugging a broken form on a customer site means asking them to open DevTools and paste what they see. The reCAPTCHA `invalid-input-response` and the intl-tel-input clobber chased recently were both visible only in the visitor's browser and left no trace on the server. Adds an Enable Logs toggle to General settings. While on, the plugin records form-submission failures reported by the browser — the HTTP status and duration of the submit request, the raw response body when it is not JSON, and the error behind the failure — to a single size-capped file an admin downloads from the settings screen. The capture points sit inside submitFormData rather than on the existing CustomEvents, because submitFormData never throws: its catch swallows everything and returns a synthetic object, so the failure events carry only the string the visitor already saw. The most valuable line is the response.clone().text() in parseRestResponse — it is what shows a WAF block page, a PHP fatal, or a warning printed ahead of the JSON. Shipping uses a plain fetch, not sendBeacon, because the submit token travels in a header and sendBeacon cannot set one. Server-side controls, in order of what actually carries weight: - The stored setting is the authority and is re-checked on every write. The frontend flag is baked into cached HTML and can be a whole cache TTL out of date, so the route 404s when logging is off. - The endpoint accepts a fixed schema and reconstructs the line itself. Redaction governs values; schema validation governs shape. Without it an anonymous caller writes arbitrary text into a file an admin opens. - Rate limited per IP and form, reusing the existing transient limiter rather than duplicating it, including its fail-closed behaviour. - The submit token is required for consistency, and the docblock is explicit that it is per-form, not per-visitor, and scrapeable from one GET — it filters scanners and nothing more. No IP and no User-Agent are recorded: form-submit.php already blanks the IP under either the global toggle or the per-form GDPR flag, and a logger that ignored both would be a compliance regression. Field keys are kept, values never are, and free text is scrubbed of emails, long digit runs and URL query strings. The log caps and stops rather than trimming or rotating — the person who reproduced the bug is the one whose lines eviction would discard — and the settings screen says so when it is full. Logging switches itself off after 7 days via the existing daily action.
check-test-coverage matches on an exact test_<function_name> prefix, so these four needed named tests in the files it expects. The ajax tests assert on the emitted body rather than on the request ending. Both handlers end the request -- on rejection through wp_die(), on success through wp_send_json_success() -- so a test that only checked 'did it die' stayed green when the capability and nonce checks were removed. Caught by the revert test, not by the tests passing.
The evidence has to already exist when a support ticket arrives. A default of off means asking the reporter to enable logging and reproduce the fault, which is the round trip this feature was meant to remove -- and an expiry meant the log was reliably empty by the time anyone looked. An absent option key reads as on, so installs whose stored settings predate the setting get it too rather than silently getting nothing. The toggle still turns it off. This costs a healthy site nothing: only failures are ever written, so a site whose forms work never creates the file. Worth stating plainly, because it changes the posture: is_enabled() no longer 404s the ingest route on a default install, so that endpoint is now reachable everywhere. What holds it down is unchanged -- the fixed payload schema, the per-IP rate limit, and the 1 MB cap that stops rather than evicting. Removes ENABLED_AT_OPTION, MAX_ENABLED_DURATION, set_enabled_at(), get_expiry(), maybe_expire() and the daily cron hook.
…lap-analytics Fix: Edit Form pill overlaps form fields, add click analytics, and stop SureForms admin notices stacking
Conflict: tests/unit/admin/test-admin.php — both sides appended new test methods at the end of Test_Admin. Kept both. Dev's test_add_removable_query_args and test_maybe_track_edit_form_button_click come first, then the missing-entries-table tests, so the private helpers (break_entries_table, restore_entries_table, reset_table_cache, make_user) stay grouped at the end of the class. Dev's rename of print_thankyou_notice_styles to print_srfm_notice_styles applied cleanly; no dangling references remain.
- High: the Instant Form live-preview exclusion was dead — should_track() read $_GET on the beacon request, which never carries the previewed page's live_mode. The client now forwards a live_preview signal derived from the previewed page's own URL, and should_track() reads it off the beacon request, so a non-privileged preview session is excluded too. - Medium: make the non-object-cache rate-limit path atomic with a MySQL advisory lock (GET_LOCK, timeout 0) so a concurrent burst can't each read the same value and admit past the ceiling; deny on contention, fall back to best-effort where GET_LOCK is unsupported. - Medium: return metric_sort_applied so the Forms table resets a stale sort arrow when the order silently fell back to date (feature off, or past the >500-form metric-sort ceiling). - Low: expose the per-network and per-form view ceilings via srfm_form_views_rate_limit_max / _form_max filters. Adds regression tests for the live-preview exclusion on an anonymous (non-privileged) beacon session.
…ies-table feat: detect a missing srfm_entries table and offer a one-click repair
…iews-conversion-nr Feat: Views & Conversion Rate columns in the Forms table (#2973)
Replaces the single sidebar card with a SureRank-style Form Checks list. Rows come from one server feed, so a new check needs no JavaScript change, and an srfm_action_items filter lets pro contribute. Row markup mirrors SureRank's CheckCard so the two plugins read as siblings. Red triangle for a fault losing entries, amber for advice, green tick for a passing check -- a healthy site sees the panel confirm it, rather than the panel vanishing. Adds detection for the twenty most-used caching and optimisation plugins. A cached page serves the same HTML to everyone: the submit token is embedded at render time and a JS-combining setup can reorder the scripts a form depends on. Detection mirrors is_any_smtp_plugin_active(), multisite network-active merge included, and is tested path by path -- a typo in one of twenty means those users silently never see the advice. The classic notices now follow the admin across wp-admin rather than sitting only on the dashboard, because someone whose forms are failing may not open the WP dashboard or SureForms for days. They stand down on SureForms' own dashboard, where the panel already lists them. Contact Support opens the mail client with the diagnostics and the recent log inline as a fenced block. mailto has no attachment field -- RFC 6068 omits it and the old attachment= parameter was removed as a file-exfiltration hole -- so the log travels in the body, tail first and whole lines only, within the length a mail client accepts. Analytics for both notices: a shown event throttled to once per user per day (the classic notice renders on every admin page, so counting renders would measure browsing, not affected sites), a CTA event, and a dismiss event recorded inside dismiss_action_item() where both the panel's cross and the no-JS link land. caching_plugin was missing from the handle_notice_response() allowlist entirely, so its clicks were being rejected and dropped. dismissible separates a fault from advice: a run of failures cannot be waved away and clears when a submission succeeds, while the caching advisory can be dismissed. The dismiss handler allowlists which ids qualify so a crafted request cannot silence the fault.
dev now carries #3085 (missing entries table) and the edit-form pill work, both of which touch admin/admin.php and its tests in the same regions as this branch. Resolved by reconstruction rather than by patching the conflict markers: git split the hunks mid-statement, so taking either side left an unterminated call. Started from dev's file and re-applied this branch's nine methods, its import, hooks, localized keys and the two analytics allowlist entries. Both features are additive and independent -- the Form Checks panel and the database repair notice each keep their own hooks and methods. Test file resolved the same way. This branch's eight tests now use dev's existing make_user() helper instead of the near-duplicate this branch had added, since dev introduced one while the branches were apart. Verified: method inventory is exactly dev's plus this branch's nine, with no duplicates and nothing dropped; PHPCS clean; both files parse.
Verified each finding against the code rather than taking it at face value; all five blocking ones were real. field_keys capped the element count but not each element's length, so a single unauthenticated POST with 100 keys of 10KB wrote 1,000,388 bytes -- 95% of the cap in one request, and a second filled it. Because the log stops rather than evicting, that silently disabled the feature until an admin cleared it. Reproduced before fixing; the same request now writes 10,388 bytes. The existing test used short fixed strings and passed throughout, so it never exercised length. The digit-run rule only matched contiguous digits, so 555-123-4567 and (555) 123-4567 -- how a phone number is actually written -- passed straight through. Separators are matched now. The URL rule stripped query strings but not fragments, so a token after # survived. Verified the reCAPTCHA error codes and HTTP statuses still come through untouched: the point is redaction, not losing the diagnosis. Clear now takes two clicks and is disabled while in flight, rather than destroying the only evidence in an active investigation on one stray click. On notice precedence I took the opposite resolution to the one suggested. The existing chain is Thank You > rating > getting started, all engagement prompts. A form that cannot accept submissions should not stand down for a review request, so the action items sit above the chain and the three promos check has_action_item_warnings() instead. That helper re-derives its conditions rather than calling get_action_items(), which records an impression and must not run from a show_if. Also fixed the non-blocking question that turned out to matter most: a throw inside field or captcha validation was captured nowhere, because handleFormSubmission's outer catch had no log call. That is exactly the intl-tel-input clobber this feature cites as its motivating example, so the feature could not have caught the bug it was written for. It now logs from that catch, before the request is ever attempted.
get_tail(), has_action_item_warnings(), handle_dismiss_action_item() and handle_dismiss_action_item_link(). Real assertions rather than name-matching stubs: the tail keeps whole JSON lines and the newest entries, the precedence guard flips with both the fault streak and a dismissal, the AJAX dismissal refuses an id that is not on the allowlist, and the no-JS link stops a subscriber before anything is dismissed.
Adi is right, and it was mine to catch. The earlier "log by default" change ran a script that hit an AssertionError partway through; I re-applied only part of it and then verified by calling Client_Logger::is_enabled() in isolation. That method was one of the two places already correct, so the check passed while the settings paths still said false. Four places now agree that an absent key means on: the save-payload read and the fresh-install default array in srfm_save_general_settings() / srfm_get_general_settings(), and both in the Abilities getter. The fresh-install array is what made this subtle. It sets the key, so the later `! isset()` backfill never fires -- the site reported the toggle OFF in the Settings UI and the Abilities API while logging was running, and the next save of any unrelated General setting persisted false and silently ended it. Verified against the real paths this time rather than the runtime alone: with the option deleted entirely, runtime, Settings UI and Abilities API all report true, and saving srfm_ip_log leaves it true. The regression test drives those same three paths and fails if either default is put back.
feat: add an Enable Logs toggle that captures submission failures to a downloadable file
Conflicts were all in the General settings plumbing, between the srfm_form_views_tracking key on this branch and srfm_enable_logs from #3086. Both are additive and independent, so every hunk keeps both. Three files, five hunks: the save-payload reads and the $settings array, the two fresh-install default arrays, the two `! isset()` backfills, the Abilities ALLOWED_KEYS list and its $boolean_keys map. Kept from #3086 in the process: srfm_enable_logs defaults to on in every path, matching Client_Logger::is_enabled(), and the srfm_log_file_size the settings screen needs to render the log row.
Dev to Next Release 2.12.6
Version Bump 2.12.6
Auto-generated by /i18n command on PR #3097
chore: update i18n translations
Three items from the review of #3097, all verified against the code first. form-views.php: get_metadata_by_mid() returns false for a row that has gone since the ids were read, and `?? 0` does not cover that -- reading a property on false is a warning in its own right, logged under WP_DEBUG_LOG on every run even though the total stayed correct. Guarded explicitly and the row skipped. General.js: the clear-log request never checked response.ok. fetch only rejects on a network failure, so a 403 from a stale nonce or a 500 resolved normally and the catch never ran. Worse than the reported "success toast on failure": it also zeroed logMeta.size, which hides the whole row, so the log looked cleared while the file was still on disk. Confirmed the endpoint answers HTTP 400 on a bad nonce, so response.ok is the right signal. admin.php: get_support_mailto_url() took a $count it immediately unset and recomputed internally. Removed, along with its @PARAM. Its docblock also still described downloading the log alongside the email, which stopped being true when the log moved inline into the body -- corrected rather than left to mislead the next reader.
fix: address the pre-merge findings on the 2.12.6 release PR
…e to 2nd September Clicking Contact Support now records the report and hides the notice. It comes back the moment something fails that has not been reported -- so a site owner who has already raised a ticket is not nagged, but a new fault is never hidden behind an old one. Recorded server-side in handle_notice_response() rather than in the browser, so it holds for the classic wp-admin notice too, which is a plain link with no JavaScript. The record stores both a timestamp and the fault count, and the count is what the comparison uses. A timestamp cannot decide this reliably: the click and the fault are both written to the second, so a fault landing in the same second would compare as not-newer and be suppressed -- hiding a failure nobody reported. The counter is monotonic, so it is exact whatever the timing. Verified by a test that reproduces the same-second case and fails if the comparison is put back on the clock. The timestamp is kept because it is what tells support when the report was made. A successful submission clears the acknowledgement along with the streak, so the next run of failures is judged on its own rather than against a stale report. Release date moved to 2nd September 2026 in README.md and readme.txt.
Also pins that a 'blocked' entry -- a stop the visitor can clear themselves -- does not move it, since only faults should.
…-and-failure-ack 2.12.6: retire the submission-failure notice once reported, and correct the release date
…ailures Adds distinct notices for email-notification and integration failures, each naming the form it happened on, and makes the existing submission notice name its form too. "A form is failing" is not actionable on a site with twenty of them. The three are tracked as separate categories because they read completely differently to a site owner: submissions failing means visitors cannot reach you, a notification failing means you are not hearing about entries that did save, an integration failing means a third party is not receiving them. Collapsing them into one warning would describe none of those accurately. Reporting one leaves the other two showing, and a submission getting through clears only its own category -- it says nothing about whether the email sent. Also unifies the state, which is the part worth reviewing. Adding the category store left two sources of truth for "are submissions failing": get_action_items() read the new store while has_action_item_warnings() still read the old streak option, so the sidebar panel and the promotional-notice suppression could disagree. Caught by a test that went red rather than by reading the diff. FAULT_STREAK_OPTION, LAST_FAULT_OPTION and ACKNOWLEDGED_OPTION are gone and everything reads the one store. Integration failures need a pro-side change to be observable at all: pro writes webhook and native-integration outcomes to the entry's own log and fires no action. This adds the srfm_integration_failed hook and the free side of the wiring; pro must fire it for that notice to appear.
get_failures(), get_open_failures() and record_integration_failure(). Real assertions rather than name-matching stubs. get_failures() pins that a reported category stays on record while dropping out of get_open_failures(), which is the distinction between "reported" and "gone away". get_open_failures() reproduces the same-second case that made a count comparison necessary in the first place. record_integration_failure() goes through the srfm_integration_failed action rather than calling the method, so the wiring pro depends on is covered too, and asserts the failure lands in its own category rather than counting as a submission failure.
…ogging feat: detailed failure reasons, and separate notices for submission, notification and integration failures
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sync of
brainstormforce/sureforms@masterinto the public mirror.Range
3f7d8b98..4ffebe71The branch is capped with a merge commit whose first parent is
master, so this diff is computed against the mirror and contains only real upstream changes — no internal-file deletions.Cross-check: the 117-file set in this PR is byte-for-byte identical to
git diff 24ccb4ff4..4ffebe712on private master. The strip contributed zero deletions to the public diff, confirming nothing internal was on the mirror to begin with.Highlights — the 2.12.6 release window
srfm_entriestable and offers a one-click repairplugin_activatedreferer read to shutdown, avoiding a raceStrip
Removed before signing: all
CLAUDE.mdat any depth,.claude/,docs/,internal-docs/,.scripts/git-hooks/,ARCHITECTURE.md,COMPREHENSIVE_ANALYSIS.md,PRODUCT_ANALYSIS.md,TECHNICAL_OVERVIEW.md,tests/play/specs/TODO.md, the five internal release workflows, andbin/{build-zip.sh,checkout-and-build,i18n.sh}.Post-strip check confirms the only markdown published is
README.md,inc/abilities/ABILITIES.md,modules/gutenberg/readme.md,tests/play/README.md, and third-partyinc/lib/**.Signatures
All 743 commits in the re-signed range carry signatures — verified by grepping
gpgsigon every commit object, zero unsigned — under a single committer identity. GitHub reportsverified=true, reason=validon both the branch tip and the strip commit.