PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 All 97 releases
sureforms / inc / forms-data.php

forms-data.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.7, at inc/forms-data.php

737 lines 26.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sureforms get forms title and Ids.
4 *
5 * @package sureforms.
6 * @since 0.0.1
7 */
8
9 namespace SRFM\Inc;
10
11 use SRFM\Inc\Database\Tables\Entries;
12 use SRFM\Inc\Traits\Get_Instance;
13 use WP_Error;
14 use WP_REST_Response;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * Load Defaults Class.
22 *
23 * @since 0.0.1
24 */
25 class Forms_Data {
26 use Get_Instance;
27
28 /**
29 * Ids of the in-window submitters who can edit the site, keyed by blog id.
30 *
31 * Keyed on the blog rather than held as a single list because capabilities are
32 * per-site: after a switch_to_blog() the previous site's answer is wrong, and a
33 * flat cache would hand it back.
34 *
35 * A class property rather than a `static` inside the method so it can be reset,
36 * which the tests need: they create a user and then ask for the metrics inside
37 * one process.
38 *
39 * Deliberately per-request and never written to the object cache. A persistent
40 * cache would keep counting a newly promoted editor as a visitor until
41 * something invalidated it, and there is no natural invalidation point.
42 *
43 * @var array<int,array<int,int>>|null
44 * @since 2.12.7
45 */
46 private static $editing_user_ids = null;
47
48 /**
49 * Constructor
50 *
51 * @since 0.0.1
52 */
53 public function __construct() {
54 add_action( 'rest_api_init', [ $this, 'register_custom_endpoint' ] );
55 }
56
57 /**
58 * Add custom API Route load-form-defaults
59 *
60 * @return void
61 * @since 0.0.1
62 */
63 public function register_custom_endpoint() {
64 register_rest_route(
65 'sureforms/v1',
66 '/forms-data',
67 [
68 'methods' => 'GET',
69 'callback' => [ $this, 'load_forms' ],
70 'permission_callback' => [ $this, 'get_form_permissions_check' ],
71 ]
72 );
73 }
74
75 /**
76 * Checks whether a given request has permission to read the form.
77 *
78 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
79 * @since 0.0.1
80 */
81 public function get_form_permissions_check() {
82 if ( Helper::current_user_can( 'edit_posts' ) ) {
83 return true;
84 }
85
86 return new \WP_Error(
87 'rest_cannot_view',
88 __( 'Sorry, you are not allowed to view the form.', 'sureforms' ),
89 [ 'status' => \rest_authorization_required_code() ]
90 );
91 }
92
93 /**
94 * Handle Form status
95 *
96 * @param \WP_REST_Request $request Full details about the request.
97 *
98 * @return WP_REST_Response
99 * @since 0.0.1
100 */
101 public function load_forms( $request ) {
102
103 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
104
105 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
106 wp_send_json_error(
107 [
108 'data' => __( 'Nonce verification failed.', 'sureforms' ),
109 'status' => false,
110 ]
111 );
112 }
113
114 $args = [
115 'post_type' => 'sureforms_form',
116 'post_status' => 'publish',
117 'posts_per_page' => -1, // Retrieve all posts.
118 ];
119
120 $form_posts = get_posts( $args );
121
122 $data = [];
123
124 foreach ( $form_posts as $post ) {
125 $data[] = [
126 'id' => $post->ID,
127 'title' => $post->post_title,
128 'content' => $post->post_content,
129 ];
130 }
131
132 return new WP_REST_Response( $data );
133 }
134
135 /**
136 * Get forms list for the forms listing page.
137 *
138 * @param \WP_REST_Request $request Full details about the request.
139 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
140 * @since 2.0.0
141 */
142 public function get_forms_list( $request ) {
143 $nonce = sanitize_text_field( Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) ) );
144
145 Helper::verify_nonce_and_capabilities( 'rest', $nonce, 'wp_rest' );
146
147 // Get and validate request parameters.
148 $page = max( 1, Helper::get_integer_value( $request->get_param( 'page' ) ) );
149 $status = sanitize_text_field( $request->get_param( 'status' ) );
150
151 // Get per_page from option first, then request parameter, with fallback to 10.
152 $saved_per_page = Helper::get_srfm_option( 'forms_per_page', 10 );
153 $request_per_page = $request->get_param( 'per_page' );
154 $per_page = $request_per_page ? min( 100, max( 1, Helper::get_integer_value( $request_per_page ) ) ) : $saved_per_page;
155
156 // Save per_page to option if it came from request.
157 if ( $request_per_page && 'trash' !== $status && 1 < $request_per_page ) {
158 Helper::update_srfm_option( 'forms_per_page', $per_page );
159 }
160
161 $search = sanitize_text_field( $request->get_param( 'search' ) );
162 $orderby = sanitize_text_field( $request->get_param( 'orderby' ) );
163 $order = sanitize_text_field( $request->get_param( 'order' ) );
164 $date_from = sanitize_text_field( $request->get_param( 'after' ) );
165 $date_to = sanitize_text_field( $request->get_param( 'before' ) );
166
167 // Build query arguments.
168 $args = [
169 'post_type' => SRFM_FORMS_POST_TYPE,
170 'post_status' => 'any' === $status ? [ 'publish', 'draft' ] : $status,
171 'posts_per_page' => $per_page,
172 'paged' => $page,
173 'orderby' => $orderby,
174 'order' => $order,
175 ];
176
177 // Add date range filtering.
178 if ( ! empty( $date_from ) || ! empty( $date_to ) ) {
179 $date_query = [];
180 // Handle 'after' date.
181 if ( ! empty( $date_from ) ) {
182 $date_query['after'] = $date_from;
183 }
184
185 // Handle 'before' date - add 1 day to include the full end date.
186 if ( ! empty( $date_to ) ) {
187 $end_date = new \DateTime( $date_to );
188 $end_date->add( new \DateInterval( 'P1D' ) ); // Add 1 day.
189 $date_query['before'] = $end_date->format( 'Y-m-d' );
190 }
191
192 $date_query['inclusive'] = true;
193 $args['date_query'] = [ $date_query ];
194 }
195
196 // Add search parameter.
197 if ( ! empty( $search ) ) {
198 if ( is_numeric( $search ) ) {
199 // Numeric search: match by form ID and title (e.g., "2024 Survey").
200 $numeric_search = $search;
201 $where_filter = static function ( $where, $query ) use ( $numeric_search ) {
202 if ( ! $query->get( 'srfm_numeric_search' ) ) {
203 return $where;
204 }
205 global $wpdb;
206 $where .= $wpdb->prepare(
207 " AND ({$wpdb->posts}.ID = %d OR {$wpdb->posts}.post_title LIKE %s)",
208 absint( $numeric_search ),
209 '%' . $wpdb->esc_like( $numeric_search ) . '%'
210 );
211 return $where;
212 };
213
214 add_filter( 'posts_where', $where_filter, 10, 2 );
215 $args['srfm_numeric_search'] = true;
216 } else {
217 // Text search: match by title only.
218 $args['s'] = $search;
219 $args['search_columns'] = [ 'post_title' ];
220 }
221 }
222
223 // Execute query — use try/finally to guarantee filter cleanup.
224 try {
225 // Derived metrics can't be sorted by WP_Query, so they take a
226 // compute-sort-paginate pass instead. Only while tracking is on: with the
227 // feature off the columns are hidden, and a stored or hand-crafted request
228 // would run that expensive pass to order rows nobody can see. It also
229 // returns null when the site has more forms than the pass will scan.
230 $response_data = null;
231 $is_metric_sort = in_array( $orderby, [ 'views', 'conversion_rate' ], true );
232
233 if ( $is_metric_sort && Form_Views::get_instance()->is_tracking_enabled() ) {
234 $response_data = $this->get_forms_sorted_by_metric( $args, $orderby, $order, $page, Helper::get_integer_value( $per_page ) );
235 }
236
237 // True only when the metric sort actually ran. It returns null and falls back
238 // to date order when the feature is off or the site is past the sort ceiling;
239 // the table needs to know so it does not leave an active sort arrow on a
240 // column whose order was silently ignored.
241 $metric_sort_applied = $is_metric_sort && null !== $response_data;
242
243 if ( null === $response_data ) {
244 if ( $is_metric_sort ) {
245 // WP_Query would silently discard 'views'/'conversion_rate' and fall
246 // back to post_date anyway. Say so explicitly so the behaviour is in
247 // the code rather than in core's tolerance for unknown keys.
248 $args['orderby'] = 'date';
249 }
250
251 $query = new \WP_Query( $args );
252
253 $forms = [];
254 /**
255 * Post object from the query.
256 *
257 * @var \WP_Post $post */
258 foreach ( $query->posts as $post ) {
259 $forms[] = $this->prepare_form_for_listing( $post );
260 }
261
262 $response_data = [
263 'forms' => $forms,
264 'total' => Helper::get_integer_value( $query->found_posts ),
265 'total_pages' => Helper::get_integer_value( $query->max_num_pages ),
266 'current_page' => $page,
267 'per_page' => $per_page,
268 ];
269 }
270 } finally {
271 if ( ! empty( $search ) && is_numeric( $search ) && isset( $where_filter ) ) {
272 remove_filter( 'posts_where', $where_filter, 10 );
273 }
274 }
275
276 // Travels with the rows so the table gates its columns on the same evaluation
277 // that produced them. The localized `srfm_admin` flag is only a page-load
278 // snapshot: toggle the setting in another tab and the open list would keep
279 // rendering columns while every row came back empty, which reads as data loss.
280 $response_data['views_enabled'] = Form_Views::get_instance()->is_tracking_enabled();
281
282 // Lets the table reset a stale metric sort arrow when the order silently fell
283 // back to date (feature off, or past the metric-sort ceiling).
284 $response_data['metric_sort_applied'] = $metric_sort_applied;
285
286 return new WP_REST_Response( $response_data, 200 );
287 }
288
289 /**
290 * Sort the forms list by a derived metric (views or conversion rate) and paginate.
291 *
292 * WP_Query can't order by views (missing-meta forms would drop out) or by the
293 * derived conversion rate at all, so we fetch every matching form id, compute the
294 * metric, sort in PHP, then slice the requested page. Form counts are small in
295 * practice; revisit with a grouped query if a site accumulates thousands of forms.
296 *
297 * @param array<string,mixed> $args Base WP_Query args (filters/search), pagination ignored.
298 * @param string $orderby Either 'views' or 'conversion_rate'.
299 * @param string $order 'asc' or 'desc'.
300 * @param int $page Current page (1-based).
301 * @param int $per_page Items per page.
302 * @since 2.12.6
303 * @return array<string,mixed>|null Response payload, or null when the site has more
304 * forms than this pass will scan and the caller
305 * should fall back to ordinary ordering.
306 */
307 private function get_forms_sorted_by_metric( $args, $orderby, $order, $page, $per_page ) {
308 $id_args = $args;
309 $id_args['posts_per_page'] = -1;
310 $id_args['paged'] = 1;
311 $id_args['fields'] = 'ids';
312 $id_args['orderby'] = 'ID';
313 $id_args['order'] = 'DESC';
314
315 $id_query = new \WP_Query( $id_args );
316
317 /**
318 * Largest number of forms this path will sort before giving up.
319 *
320 * The pass is one entry COUNT per form, so cost grows linearly with the form
321 * count while only one page is ever displayed. Past this ceiling the request
322 * returns null so the caller falls back to ordinary date ordering rather than
323 * firing thousands of queries to render ten rows.
324 *
325 * @param int $limit Maximum forms to sort in PHP. Default 500.
326 * @since 2.12.6
327 */
328 $limit = Helper::get_integer_value( apply_filters( 'srfm_forms_metric_sort_limit', 500 ) );
329
330 // A filter is allowed to tighten or loosen the ceiling, not to remove it. Zero
331 // or negative read as "no ceiling" to a `> $limit` test, which is the one
332 // outcome the ceiling exists to prevent, so fall back to the default.
333 $limit = $limit > 0 ? $limit : 500;
334
335 if ( count( $id_query->posts ) > $limit ) {
336 /**
337 * Fires when the metric sort is skipped because the site has too many forms.
338 *
339 * Announced rather than skipped silently: a list that quietly ignores the
340 * column the user clicked reads as a broken sort, not as a deliberate
341 * ceiling. Gives a site owner something to hook if they hit it.
342 *
343 * @param string $orderby Requested metric, 'views' or 'conversion_rate'.
344 * @param int $count Number of forms that would have been sorted.
345 * @param int $limit The ceiling in force.
346 * @since 2.12.6
347 */
348 do_action( 'srfm_forms_metric_sort_skipped', $orderby, count( $id_query->posts ), $limit );
349
350 return null;
351 }
352
353 // `fields => ids` skips the post-meta cache priming that a normal WP_Query does,
354 // so prime it once for all matched forms — otherwise each get_views() below is a
355 // separate get_post_meta() query (N+1). Entry counts are still one COUNT per form;
356 // acceptable for typical form volumes, revisit with a grouped query if needed.
357 if ( ! empty( $id_query->posts ) ) {
358 $prime_ids = array_map(
359 static function ( $post ) {
360 return (int) ( $post instanceof \WP_Post ? $post->ID : $post );
361 },
362 $id_query->posts
363 );
364 update_meta_cache( 'post', $prime_ids );
365
366 // `fields => ids` skips the post cache, so the get_post() in the pagination
367 // loop below would be one query per displayed row. Prime it here instead.
368 // Terms and meta are handled separately, hence both flags false.
369 _prime_post_caches( $prime_ids, false, false );
370 }
371
372 $rows = [];
373 foreach ( $id_query->posts as $post_id ) {
374 $form_id = Helper::get_integer_value( $post_id );
375
376 // The form id is the whole input, here and on the render path, so the two
377 // cannot be handed different arguments and compute different numbers.
378 // They once could: this took a creation date and an all-time count as
379 // well, the two callers passed them differently, and the column and its
380 // sort order disagreed.
381 $metrics = $this->calculate_form_metrics( $form_id );
382
383 // Same helper the column renders from, so the order always matches the
384 // numbers on screen. An unmeasurable rate sorts as -1 rather than 0, so
385 // the dash rows group below a genuine 0% instead of tying with it.
386 $metric = 'views' === $orderby
387 ? (float) $metrics['views']
388 : ( null === $metrics['conversion_rate'] ? -1.0 : (float) $metrics['conversion_rate'] );
389
390 $rows[] = [
391 'id' => $form_id,
392 'metric' => $metric,
393 ];
394 }
395
396 // Sort by metric, tie-break on id (desc) for a stable order.
397 $direction = 'asc' === strtolower( $order ) ? 1 : -1;
398 usort(
399 $rows,
400 static function ( $a, $b ) use ( $direction ) {
401 if ( $a['metric'] === $b['metric'] ) {
402 return $b['id'] <=> $a['id'];
403 }
404 return ( $a['metric'] <=> $b['metric'] ) * $direction;
405 }
406 );
407
408 $total = count( $rows );
409 $total_pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1;
410 $offset = ( $page - 1 ) * $per_page;
411 $page_rows = array_slice( $rows, max( 0, $offset ), $per_page );
412
413 $forms = [];
414 foreach ( $page_rows as $row ) {
415 $post = get_post( $row['id'] );
416 if ( $post instanceof \WP_Post ) {
417 $forms[] = $this->prepare_form_for_listing( $post );
418 }
419 }
420
421 return [
422 'forms' => $forms,
423 'total' => $total,
424 'total_pages' => $total_pages,
425 'current_page' => $page,
426 'per_page' => $per_page,
427 ];
428 }
429
430 /**
431 * The tracking-window boundary as a datetime string comparable to `created_at`.
432 *
433 * `created_at` is written by MySQL (`DEFAULT CURRENT_TIMESTAMP`) and compared in
434 * the session time zone, not UTC, so a bare `gmdate()` of a PHP timestamp is off
435 * by the MySQL/PHP clock offset — permanently mis-counting entries near the
436 * boundary. Entries::get_entries_count_after() solves this by reading
437 * `SELECT NOW()`, but it does so on every call, which would be one extra query
438 * per row on a listing page. The offset cannot change within a request, so it is
439 * resolved once and reused.
440 *
441 * @param int $window_start Unix timestamp.
442 * @return string Datetime string in MySQL's frame of reference.
443 * @since 2.12.6
444 */
445 private static function window_boundary_sql( $window_start ) {
446 static $offset_seconds = null;
447
448 if ( null === $offset_seconds ) {
449 global $wpdb;
450
451 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reading the server clock; a cached value would defeat the purpose.
452 $mysql_now = $wpdb->get_var( 'SELECT NOW()' );
453 $mysql_ts = $mysql_now ? strtotime( (string) $mysql_now ) : false;
454
455 // Fall back to no adjustment rather than a wild offset if NOW() is
456 // unreadable or unparseable — a UTC-configured server needs none anyway.
457 $offset_seconds = false === $mysql_ts ? 0 : $mysql_ts - time();
458 }
459
460 return gmdate( 'Y-m-d H:i:s', $window_start + $offset_seconds );
461 }
462
463 /**
464 * Ids of the in-window submitters who can edit the site, cached for the request.
465 *
466 * Bounded by submitters, not by users. Asking the user table for everyone with
467 * `edit_posts` runs an unindexable leading-wildcard scan of capability meta with
468 * no LIMIT, and the answer is then interpolated into one windowed COUNT per form,
469 * up to the ceiling in get_forms_sorted_by_metric(). On a membership site that
470 * hands `contributor` to every member that is a five-figure placeholder list
471 * rebuilt for every row of a ten-row page. Only people who actually submitted
472 * inside the window can affect the rate, and that set is small.
473 *
474 * Served by `idx_user_id_created_at (user_id, created_at)`, added for this
475 * lookup. `idx_user_id` alone cannot: `created_at` is not in it and no other
476 * index leads on `created_at`, so before that index this was a range scan with
477 * a row read per row plus a temp table for the DISTINCT -- on a site with years
478 * of logged-in submissions, every entry ever recorded, to return a short list.
479 * The LIMIT bounds what comes back, not what is read; the index bounds the
480 * read.
481 *
482 * Decided with `user_can()`, the same call Form_Views::should_track() makes, so
483 * both halves of the rate answer one question rather than two similar ones. A
484 * capability granted at runtime through `user_has_cap`, a multisite super admin
485 * who is not a member of the subsite, and a role carrying `edit_posts` as an
486 * explicit denial all resolve the same way on both sides.
487 *
488 * Reflects capability as it stands now, not as it stood at submission time. On a
489 * site that promotes or demotes people the two halves still drift: a promoted
490 * subscriber's earlier views stay in the denominator while their earlier entries
491 * leave the numerator, and a demoted editor's earlier entries return without
492 * their views. Closing that means stamping the decision on the entry at submit
493 * time, which is a schema change this percentage does not justify.
494 *
495 * Cached because the forms listing asks once per row, and the answer cannot
496 * change within a request.
497 *
498 * @param int $window_start Unix timestamp the view window opened at.
499 * @since 2.12.7
500 * @return array<int,int>
501 */
502 private static function get_editing_submitter_ids( $window_start ) {
503 $blog_id = get_current_blog_id();
504
505 if ( null === self::$editing_user_ids ) {
506 self::$editing_user_ids = [];
507 }
508
509 if ( isset( self::$editing_user_ids[ $blog_id ] ) ) {
510 return self::$editing_user_ids[ $blog_id ];
511 }
512
513 /**
514 * Largest number of distinct in-window submitters to test for edit access.
515 *
516 * The test is bounded work per submitter, so this is a guard against a site
517 * where a very large share of submissions are made while logged in. Past the
518 * ceiling the exclusion is skipped rather than truncated: a partial exclusion
519 * list reports a rate that is wrong in a way nobody can see, where no
520 * exclusion at least reproduces the pre-existing behaviour.
521 *
522 * Two things to know before raising or lowering it. The count is of
523 * distinct submitters across all forms since tracking was first enabled,
524 * and that window never resets -- so a membership site, a store or an LMS
525 * reaches 500 in ordinary operation, and once passed it stays passed, with
526 * the rate quietly counting editor submissions again. That is why the
527 * settings copy says those are "normally" left out rather than promising it
528 * outright. And the query does not filter on status, so a user whose only
529 * in-window entries were trashed still lands on the list and consumes
530 * budget -- harmless for the count, but it brings the ceiling closer.
531 *
532 * @param int $limit Maximum submitters to test. Default 500.
533 * @since 2.12.7
534 */
535 $limit = Helper::get_integer_value( apply_filters( 'srfm_forms_metric_submitter_limit', 500 ) );
536
537 // Same reasoning as the sort ceiling: a filter may move it, not remove it.
538 $limit = $limit > 0 ? $limit : 500;
539
540 // One row past the ceiling, so a full page is proof the ceiling was passed
541 // without counting the rest of the table to find out.
542 $rows = Entries::get_instance()->get_results(
543 [
544 [
545 [
546 'key' => 'created_at',
547 'compare' => '>=',
548 'value' => self::window_boundary_sql( $window_start ),
549 ],
550 [
551 'key' => 'user_id',
552 'compare' => '>',
553 'value' => 0,
554 ],
555 ],
556 ],
557 'DISTINCT user_id',
558 [ sprintf( 'LIMIT %d', $limit + 1 ) ]
559 );
560
561 $submitters = array_values( array_unique( array_map( 'absint', array_column( $rows, 'user_id' ) ) ) );
562
563 if ( count( $submitters ) > $limit ) {
564 /**
565 * Fires when the editor exclusion is skipped because too many submitters
566 * would have to be tested.
567 *
568 * Announced rather than skipped silently, for the same reason as
569 * `srfm_forms_metric_sort_skipped`: a rate that quietly stops excluding
570 * editors reads as a wrong number, not as a deliberate ceiling.
571 *
572 * @param int $count Number of distinct submitters found, capped at $limit + 1.
573 * @param int $limit The ceiling in force.
574 * @since 2.12.7
575 */
576 do_action( 'srfm_forms_metric_submitter_limit_exceeded', count( $submitters ), $limit );
577
578 self::$editing_user_ids[ $blog_id ] = [];
579
580 return self::$editing_user_ids[ $blog_id ];
581 }
582
583 if ( [] !== $submitters ) {
584 // Two queries for the whole set. Without it user_can() resolves each user
585 // on its own and the loop becomes one query per submitter.
586 cache_users( $submitters );
587 }
588
589 self::$editing_user_ids[ $blog_id ] = array_values(
590 array_filter(
591 $submitters,
592 static function ( $user_id ) {
593 return user_can( $user_id, 'edit_posts' );
594 }
595 )
596 );
597
598 return self::$editing_user_ids[ $blog_id ];
599 }
600
601 /**
602 * Views and conversion rate for one form.
603 *
604 * The single source of truth for both the rendered value and the sorted metric.
605 * They were computed separately at first, and drifted: the sort used all-time
606 * entries while the column used entries from the tracking window, so a form
607 * rendering a dash sorted as though its rate were several hundred percent.
608 * Anything needing these numbers must come through here.
609 *
610 * Both halves apply the same test. Views are not counted for anyone who can
611 * edit the site (Form_Views::should_track()), so their submissions must not be
612 * counted either: testing your own form five times would otherwise add five to
613 * the numerator and nothing to the denominator, and report a rate several times
614 * the real one. The test is applied to capability as it stands now on both
615 * sides, so a site that promotes or demotes people still sees some drift --
616 * get_editing_submitter_ids() has the detail. The Entries column is unaffected
617 * and stays a true all-time count of every entry received.
618 *
619 * Returns `null` for the rate rather than a number whenever it cannot be
620 * measured: tracking off, window never opened, no views yet, or more entries
621 * than views. That last case means the view count is incomplete, or that a
622 * submitter was promoted after submitting, and any percentage would be invented;
623 * the table renders the dash instead. `0.0` is reserved for a real measurement
624 * of zero.
625 *
626 * @param int $form_id Form post ID.
627 * @return array{views:int,conversion_rate:float|null}
628 * @since 2.12.7 -- Signature reduced to $form_id.
629 * @since 2.12.6
630 */
631 private function calculate_form_metrics( $form_id ) {
632 $none = [
633 'views' => 0,
634 'conversion_rate' => null,
635 ];
636
637 $window_start = Form_Views::get_instance()->get_tracking_started_at();
638
639 // A zero stamp means counting never started, so there is nothing to divide by
640 // and no window to measure against. Guarded as well as the display toggle
641 // because the two are written by different paths: the toggle could be forced
642 // on by a direct option write that never ran maybe_start_tracking(), and
643 // gmdate() on a zero timestamp would silently widen the window to 1970 and
644 // count every entry the form has ever had.
645 if ( ! Form_Views::get_instance()->is_tracking_enabled() || $window_start <= 0 ) {
646 return $none;
647 }
648
649 $views = Form_Views::get_instance()->get_views( $form_id );
650
651 if ( $views <= 0 ) {
652 return $none;
653 }
654
655 // Compare like with like. The Entries column is all-time, but views only start
656 // accruing when tracking opens, so the rate counts entries from that same
657 // moment. Otherwise a form that existed beforehand divides years of entries
658 // by days of views and reports a rate that is pure noise.
659 $where = [
660 [
661 [
662 'key' => 'created_at',
663 'compare' => '>=',
664 'value' => self::window_boundary_sql( $window_start ),
665 ],
666 ],
667 ];
668
669 $editing_users = self::get_editing_submitter_ids( $window_start );
670
671 if ( [] !== $editing_users ) {
672 $where[] = [
673 [
674 'key' => 'user_id',
675 'compare' => 'NOT IN',
676 'value' => $editing_users,
677 ],
678 ];
679 }
680
681 // Always counted, never taken from the caller's all-time total. That total
682 // includes the entries this exclusion exists to drop, so reusing it for a
683 // form created inside the window -- the newly built form an admin has just
684 // been testing, which is exactly the case that skews -- would hand back the
685 // unfiltered number and quietly undo the exclusion.
686 $entries_since = Helper::get_integer_value(
687 Entries::get_total_entries_by_status( 'all', $form_id, $where )
688 );
689
690 if ( $entries_since > $views ) {
691 return [
692 'views' => $views,
693 'conversion_rate' => null,
694 ];
695 }
696
697 return [
698 'views' => $views,
699 'conversion_rate' => round( $entries_since / $views * 100, 1 ),
700 ];
701 }
702
703 /**
704 * Prepare a single form for the listing response.
705 *
706 * @param \WP_Post $post Post object.
707 * @return array<mixed> Prepared form data for listing.
708 * @since 2.0.0
709 */
710 private function prepare_form_for_listing( $post ) {
711 $form_id = $post->ID;
712
713 // Get entries count.
714 $entries_count = Helper::get_integer_value( Entries::get_total_entries_by_status( 'all', $form_id ) );
715
716 // Views and conversion rate come from the same helper the sort path uses, so the
717 // column can never order by a different number than it displays.
718 $metrics = $this->calculate_form_metrics( $form_id );
719 $views = $metrics['views'];
720 $conversion_rate = $metrics['conversion_rate'];
721
722 return [
723 'id' => $form_id,
724 'title' => $post->post_title,
725 'status' => $post->post_status,
726 'date_created' => mysql_to_rfc3339( $post->post_date ),
727 'date_modified' => mysql_to_rfc3339( $post->post_modified ),
728 'entries_count' => $entries_count,
729 'views' => $views,
730 'conversion_rate' => $conversion_rate,
731 'shortcode' => "[sureforms id='{$form_id}']",
732 'edit_url' => admin_url( "post.php?post={$form_id}&action=edit" ),
733 'frontend_url' => get_permalink( $form_id ),
734 ];
735 }
736 }
737