PluginProbe
Hash Form – Drag & Drop Form Builder / trunk
Hash Form – Drag & Drop Form Builder vtrunk
1.4.4 1.4.3 1.4.2 1.4.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.6.1 1.2.7 1.2.8 1.2.9 1.3.0 All 47 releases
hash-form / includes / HashFormEntryListing.php

HashFormEntryListing.php in Hash Form – Drag & Drop Form Builder trunk, at includes/HashFormEntryListing.php

594 lines 22.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 defined('ABSPATH') || die();
3
4 /**
5 * Adding WP List table class if it's not available.
6 */
7 if (!class_exists(WP_List_Table::class)) {
8 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
9 }
10
11 class HashFormEntryListing extends \WP_List_Table {
12
13 private $status;
14 private $form_names;
15 private $previews;
16 private $page_entry_ids = array();
17
18 public function __construct() {
19 parent::__construct(
20 array(
21 'singular' => 'Entry',
22 'plural' => 'Entries',
23 'ajax' => false,
24 )
25 );
26 $this->status = HashFormHelper::get_var('status', 'sanitize_text_field', 'published');
27 }
28
29 public function no_items() {
30 esc_html_e('No entries found.', 'hash-form');
31 }
32
33 public function column_default($item, $column_name) {
34 return isset($item[$column_name]) ? $item[$column_name] : '';
35 }
36
37 public function get_columns() {
38 $columns = array(
39 'cb' => '<input type="checkbox" />',
40 'is_starred' => '<span class="dashicons dashicons-star-filled" title="' . esc_attr__('Starred', 'hash-form') . '"></span>',
41 'name' => esc_html__('ID', 'hash-form'),
42 'form_id' => esc_html__('Form', 'hash-form'),
43 'preview' => esc_html__('Entry', 'hash-form'),
44 'user_id' => esc_html__('Created By', 'hash-form'),
45 'delivery_status' => esc_html__('Status', 'hash-form'),
46 'ip' => esc_html__('IP', 'hash-form'),
47 'created_at' => esc_html__('Created At', 'hash-form')
48 );
49
50 // Add-ons append their own columns, such as payment status.
51 return apply_filters('hashform_entries_columns', $columns);
52 }
53
54 /**
55 * Unread rows are bold, the way an inbox does it.
56 */
57 public function single_row($item) {
58 $classes = empty($item['is_read']) ? 'hf-entry-unread' : '';
59 echo '<tr class="' . esc_attr($classes) . '">';
60 $this->single_row_columns($item);
61 echo '</tr>';
62 }
63
64 private function get_column_star($item) {
65 $starred = !empty($item['is_starred']);
66
67 return sprintf(
68 '<button type="button" class="hf-entry-star %1$s" data-entry="%2$s" data-starred="%3$s" aria-pressed="%4$s" aria-label="%5$s"><span class="dashicons dashicons-%6$s"></span></button>',
69 $starred ? 'hf-starred' : '',
70 esc_attr($item['id']),
71 $starred ? 1 : 0,
72 $starred ? 'true' : 'false',
73 esc_attr__('Star this entry', 'hash-form'),
74 $starred ? 'star-filled' : 'star-empty'
75 );
76 }
77
78 /**
79 * A couple of values from the entry itself, so the list can be read
80 * without opening every row.
81 */
82 private function get_column_preview($entry_id) {
83 if (null === $this->previews) {
84 $this->previews = $this->load_previews();
85 }
86
87 if (empty($this->previews[$entry_id])) {
88 return '<span class="hf-entry-preview-empty">&mdash;</span>';
89 }
90
91 $parts = array();
92
93 foreach ($this->previews[$entry_id] as $value) {
94 $parts[] = '<span class="hf-entry-preview-value">' . esc_html($value) . '</span>';
95 }
96
97 return '<div class="hf-entry-preview">' . implode('', $parts) . '</div>';
98 }
99
100 /**
101 * Loads the first couple of stored values for every entry on this page in
102 * one query, rather than one query per row.
103 */
104 private function load_previews() {
105 global $wpdb;
106
107 if (empty($this->page_entry_ids)) {
108 return array();
109 }
110
111 $ids = implode(',', array_map('absint', $this->page_entry_ids));
112
113 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $q['join'] and $q['where'] are built in build_query() from literals and placeholders only; the values they refer to are bound through $q['params']. order_clause() returns a whitelisted column. Interpolated entry ids are run through absint() first.
114 $rows = $wpdb->get_results(
115 "SELECT m.item_id, m.meta_value, f.type
116 FROM {$wpdb->prefix}hashform_entry_meta AS m
117 LEFT JOIN {$wpdb->prefix}hashform_fields AS f ON f.id = m.field_id
118 WHERE m.item_id IN ({$ids})
119 ORDER BY m.id ASC", ARRAY_A);
120 // phpcs:enable
121
122 // Layout only fields hold no answer, so they make a poor preview.
123 $skip = array('heading', 'paragraph', 'separator', 'spacer', 'image', 'html', 'captcha', 'hidden', 'user_id');
124 $previews = array();
125
126 foreach ($rows as $row) {
127 $item_id = $row['item_id'];
128
129 if (isset($previews[$item_id]) && count($previews[$item_id]) >= 2) {
130 continue;
131 }
132
133 if (in_array($row['type'], $skip, true)) {
134 continue;
135 }
136
137 /*
138 * unserialize_or_decode, not maybe_unserialize: meta_value is
139 * visitor-supplied, and maybe_unserialize() would instantiate any
140 * class named in a crafted 'O:'/'C:' payload the moment an admin
141 * opened this list. This path only ever wants an array or a string
142 * for the preview, so object serialization is decoded with native
143 * unserialize disabled (see entry-detail.php and HashFormEmail.php,
144 * which already read this column the same way).
145 */
146 $value = HashFormHelper::unserialize_or_decode($row['meta_value']);
147
148 if (is_array($value)) {
149 $value = implode(', ', array_filter(array_map('strval', $value)));
150 }
151
152 $value = trim(wp_strip_all_tags((string) $value));
153
154 if ('' === $value) {
155 continue;
156 }
157
158 $previews[$item_id][] = wp_html_excerpt($value, 45, '...');
159 }
160
161 return $previews;
162 }
163
164 public function column_cb($item) {
165 return sprintf(
166 '<input type="checkbox" name="%1$s_id[]" value="%2$s" />', esc_attr($this->_args['singular']), esc_attr($item['id'])
167 );
168 }
169
170 public function prepare_items() {
171 $hashform_columns = $this->get_columns();
172 $hashform_sortable = $this->get_sortable_columns();
173 $hashform_hidden = (is_array(get_user_meta(get_current_user_id(), 'managetoplevel_page_hashform-entriescolumnshidden', true))) ? get_user_meta(get_current_user_id(), 'managetoplevel_page_hashform-entriescolumnshidden', true) : array();
174 $hashform_primary = 'id';
175 $this->_column_headers = array($hashform_columns, $hashform_hidden, $hashform_sortable, $hashform_primary);
176
177 $per_page = $this->get_items_per_page('entries_per_page', 10);
178 $current_page = max(1, $this->get_pagenum());
179
180 /*
181 * Counted and fetched separately, so only the rows being shown are
182 * ever loaded. This used to select every entry on the site, sort the
183 * lot in php and throw all but ten of them away, which meant the
184 * screen's cost grew with the number of submissions rather than with
185 * the size of a page.
186 */
187 $total_items = $this->count_rows();
188 $page_rows = $total_items ? $this->get_table_data($per_page, ($current_page - 1) * $per_page) : array();
189
190 $this->set_pagination_args(array(
191 'total_items' => $total_items,
192 'per_page' => $per_page,
193 'total_pages' => (int) ceil($total_items / max(1, $per_page)),
194 ));
195
196 // Collected first so the previews for the whole page load in one
197 // query instead of one per row.
198 $this->page_entry_ids = wp_list_pluck($page_rows, 'id');
199
200 $data = array();
201
202 foreach ($page_rows as $item) {
203 $id = $item['id'];
204 $data[$id] = array(
205 'id' => $item['id'],
206 'is_read' => isset($item['is_read']) ? $item['is_read'] : 1,
207 'is_starred' => $this->get_column_star($item),
208 'name' => $this->get_column_id($item),
209 'form_id' => $this->get_form_link($item['form_id']),
210 'preview' => $this->get_column_preview($item['id']),
211 'user_id' => $this->get_user_link($item['user_id']),
212 'delivery_status' => $item['delivery_status'] ? esc_html__('Success', 'hash-form') : esc_html__('Failed', 'hash-form'),
213 'created_at' => HashFormHelper::convert_date_format($item['created_at']),
214 'ip' => $item['ip']
215 );
216
217 $data[$id] = apply_filters('hashform_entries_column_values', $data[$id], $item);
218 }
219
220 $this->items = $data;
221 }
222
223 public function get_column_id($item) {
224 $entry_id = $item['id'];
225
226 $edit_url = admin_url('admin.php?page=hashform-entries&hashform_action=view&id=' . $entry_id);
227
228 $output = '<strong>';
229 if ('trash' == $this->status) {
230 $output .= esc_html($entry_id);
231 } else {
232 /* translators: 1: entry id */
233 $output .= '<a class="row-title" href="' . esc_url($edit_url) . '" aria-label="' . sprintf(esc_html__('%s (Edit)', 'hash-form'), $entry_id) . '">' . esc_html($entry_id) . '</a>';
234 }
235 $output .= '</strong>';
236
237 // Get actions.
238 $actions = $this->get_action_links($item);
239 $row_actions = array();
240
241 foreach ($actions as $id => $action) {
242 $row_actions[] = '<span class="' . esc_attr($id) . '"><a href="' . $action['url'] . '">' . $action['label'] . '</a></span>';
243 }
244
245
246 $output .= '<div class="row-actions">' . implode(' | ', $row_actions) . '</div>';
247
248 return $output;
249 }
250
251
252 /**
253 * The FROM/WHERE half of the listing query, shared by the count and the
254 * page so the two can never disagree about what is being listed.
255 *
256 * @return array {
257 * @type string $join
258 * @type string $where
259 * @type array $params
260 * }
261 */
262 private function build_query() {
263 global $wpdb;
264
265 // "unread" and "starred" are filters over published entries rather
266 // than real statuses of their own.
267 $where = array('e.status = %s');
268 $params = array(in_array($this->status, array('unread', 'starred'), true) ? 'published' : $this->status);
269 $join = '';
270
271 if ('unread' === $this->status) {
272 $where[] = 'e.is_read = 0';
273 } else if ('starred' === $this->status) {
274 $where[] = 'e.is_starred = 1';
275 }
276
277 // The form filter and the search box used to be mutually exclusive,
278 // so picking a form and then searching silently ignored the form.
279 $form_id = HashFormHelper::get_var('form_id', 'absint');
280
281 if ($form_id) {
282 $where[] = 'e.form_id = %d';
283 $params[] = $form_id;
284 }
285
286 $search = trim(htmlspecialchars_decode(HashFormHelper::get_var('s')));
287
288 if ('' !== $search) {
289 $like = '%' . $wpdb->esc_like($search) . '%';
290
291 // What was submitted lives in the meta table, which is what people
292 // expect a search to look through.
293 $join = "LEFT JOIN {$wpdb->prefix}hashform_entry_meta AS m ON m.item_id = e.id
294 LEFT JOIN {$wpdb->prefix}hashform_forms AS f ON f.id = e.form_id
295 LEFT JOIN {$wpdb->users} AS u ON u.ID = e.user_id";
296
297 $search_where = array(
298 'm.meta_value LIKE %s',
299 'f.name LIKE %s',
300 'e.ip LIKE %s',
301 'u.display_name LIKE %s',
302 'u.user_email LIKE %s',
303 );
304
305 array_push($params, $like, $like, $like, $like, $like);
306
307 // A bare number is most likely an entry id.
308 if (is_numeric($search)) {
309 $search_where[] = 'e.id = %d';
310 $params[] = absint($search);
311 }
312
313 $where[] = '(' . implode(' OR ', $search_where) . ')';
314 }
315
316 return array(
317 'join' => $join,
318 'where' => implode(' AND ', $where),
319 'params' => $params,
320 );
321 }
322
323 /**
324 * The ORDER BY clause, from a whitelist.
325 *
326 * A tie-break on id is always appended. Without one, two entries sharing
327 * a created_at can swap places between one page and the next, which with
328 * LIMIT/OFFSET means a row shown twice and another never shown at all.
329 *
330 * @return string
331 */
332 private function order_clause() {
333 // The columns a caller may sort by, mapped to what they are in sql.
334 $sortable = array(
335 'id' => 'e.id',
336 'form_id' => 'e.form_id',
337 'user_id' => 'e.user_id',
338 'delivery_status' => 'e.delivery_status',
339 'status' => 'e.status',
340 'ip' => 'e.ip',
341 'created_at' => 'e.created_at',
342 );
343
344 $orderby = HashFormHelper::get_var('orderby', 'sanitize_text_field', 'created_at');
345
346 if (!isset($sortable[$orderby])) {
347 $orderby = 'created_at';
348 }
349
350 $order = 'asc' === strtolower(HashFormHelper::get_var('order', 'sanitize_text_field', 'desc')) ? 'ASC' : 'DESC';
351
352 return $sortable[$orderby] . ' ' . $order . ', e.id ' . $order;
353 }
354
355 /**
356 * How many rows the current filters match.
357 *
358 * @return int
359 */
360 private function count_rows() {
361 global $wpdb;
362
363 $q = $this->build_query();
364
365 // DISTINCT because the meta join returns one row per stored answer.
366 $sql = "SELECT COUNT(DISTINCT e.id) FROM {$wpdb->prefix}hashform_entries AS e {$q['join']} WHERE {$q['where']}";
367
368 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $q['join'] and $q['where'] are built in build_query() from literals and placeholders only; the values they refer to are bound through $q['params']. order_clause() returns a whitelisted column. Interpolated entry ids are run through absint() first.
369 return (int) ($q['params']
370 ? $wpdb->get_var($wpdb->prepare($sql, $q['params']))
371 : $wpdb->get_var($sql));
372 // phpcs:enable
373 }
374
375 /**
376 * One page of rows, ordered and limited by the database.
377 *
378 * @param int $per_page
379 * @param int $offset
380 * @return array
381 */
382 private function get_table_data($per_page, $offset) {
383 global $wpdb;
384
385 $q = $this->build_query();
386
387 // DISTINCT because the meta join returns one row per stored answer.
388 $sql = "SELECT DISTINCT e.* FROM {$wpdb->prefix}hashform_entries AS e {$q['join']}"
389 . " WHERE {$q['where']}"
390 . ' ORDER BY ' . $this->order_clause()
391 . ' LIMIT %d OFFSET %d';
392
393 $params = $q['params'];
394 $params[] = max(1, (int) $per_page);
395 $params[] = max(0, (int) $offset);
396
397 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $q['join'] and $q['where'] are built in build_query() from literals and placeholders only; the values they refer to are bound through $q['params']. order_clause() returns a whitelisted column. Interpolated entry ids are run through absint() first.
398 return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
399 // phpcs:enable
400 }
401
402 public function get_bulk_actions() {
403 if ($this->status == 'published') {
404 return array(
405 'bulk_trash' => esc_html__('Move to Trash', 'hash-form'),
406 );
407 } else {
408 return array(
409 'bulk_untrash' => esc_html__('Restore', 'hash-form'),
410 'bulk_delete' => esc_html__('Delete Permanently', 'hash-form')
411 );
412 }
413 }
414
415 protected function display_tablenav($which) {
416 if ('top' === $which) {
417 // Signs the bulk actions and "Empty Trash" submits in this form.
418 wp_nonce_field('bulk-' . $this->_args['plural']);
419 }
420 ?>
421 <div class="tablenav <?php echo esc_attr($which); ?>">
422 <?php if ($this->has_items()) { ?>
423 <div class="alignleft actions bulkactions">
424 <?php $this->bulk_actions($which); ?>
425 </div>
426 <?php
427 }
428
429 $this->extra_tablenav($which);
430
431 $this->pagination($which);
432 ?>
433 <br class="clear" />
434 </div>
435 <?php
436 }
437
438 public function extra_tablenav($which) {
439 if ($this->has_items()) {
440 if ('trash' == $this->status) {
441 ?>
442 <div class="alignleft actions"><?php submit_button(esc_html__('Empty Trash', 'hash-form'), 'apply', 'delete_all', false); ?></div>
443 <?php
444 }
445 }
446
447 if ($which === 'top') {
448 $form_id = HashFormHelper::get_var('form_id', 'absint', 0);
449 ?>
450 <div class="alignleft actions">
451 <?php
452 self::forms_dropdown('form_id', $form_id);
453 submit_button(esc_html__('Filter', 'hash-form'), 'filter_action', '', false, array('id' => 'post-query-submit'));
454
455 /*
456 * Where add-ons hang their own entry tools. Export to CSV is
457 * one of them, and it belongs to Pro - the free plugin used to
458 * put a button of its own here that only led to a sales page,
459 * which reads as a broken feature rather than an absent one.
460 */
461 do_action('hashform_entries_tablenav', $this->status, $form_id);
462 ?>
463 </div>
464 <?php
465 }
466 }
467
468 /**
469 * Exporting entries lives in the Pro plugin. Point at it only when it is
470 * not already installed.
471 */
472 public static function forms_dropdown($field_name, $field_value = '') {
473 $forms = HashFormBuilder::get_all_forms();
474 ?>
475 <select name="<?php echo esc_attr($field_name); ?>">
476 <option value=""><?php echo esc_html__('All', 'hash-form'); ?></option>
477 <?php foreach ($forms as $form) { ?>
478 <option value="<?php echo esc_attr($form->id); ?>" <?php selected($field_value, $form->id); ?>>
479 <?php echo ('' === $form->name ? esc_html__('No Title', 'hash-form') : esc_html($form->name)); ?>
480 </option>
481 <?php } ?>
482 </select>
483 <?php
484 }
485
486 public function get_sortable_columns() {
487 return array(
488 'id' => array('id', false),
489 'form_id' => array('form_id', false),
490 'user_id' => array('user_id', false),
491 'status' => array('status', false),
492 'created_at' => array('created_at', false),
493 'delivery_status' => array('delivery_status', false),
494 'ip' => array('ip', false)
495 );
496 }
497
498 public function get_action_links($item) {
499 $entry_id = $item['id'];
500 $actions = array();
501 $trash_links = self::delete_trash_links($entry_id);
502 if ('trash' == $this->status) {
503 $actions['restore'] = $trash_links['restore'];
504 $actions['delete'] = $trash_links['delete'];
505 } else {
506 $actions['view'] = array(
507 'label' => esc_html__('View', 'hash-form'),
508 'url' => admin_url('admin.php?page=hashform-entries&hashform_action=view&id=' . $entry_id)
509 );
510 $actions['trash'] = $trash_links['trash'];
511 }
512 return $actions;
513 }
514
515 private static function delete_trash_links($id) {
516 $base_url = '?page=hashform-entries&id=' . $id;
517 return array(
518 'restore' => array(
519 'label' => esc_html__('Restore', 'hash-form'),
520 'url' => wp_nonce_url($base_url . '&hashform_action=untrash', 'untrash_entry_' . absint($id)),
521 ),
522 'delete' => array(
523 'label' => esc_html__('Delete Permanently', 'hash-form'),
524 'url' => wp_nonce_url($base_url . '&hashform_action=destroy', 'destroy_entry_' . absint($id)),
525 ),
526 'trash' => array(
527 'label' => esc_html__('Trash', 'hash-form'),
528 'url' => wp_nonce_url($base_url . '&hashform_action=trash', 'trash_entry_' . absint($id)),
529 )
530 );
531 }
532
533 public function get_views() {
534 $statuses = array(
535 'published' => esc_html__('All', 'hash-form'),
536 'unread' => esc_html__('Unread', 'hash-form'),
537 'starred' => esc_html__('Starred', 'hash-form'),
538 'trash' => esc_html__('Trash', 'hash-form'),
539 );
540
541 $links = array();
542
543 $counts = HashFormEntry::get_count();
544
545 foreach ($statuses as $status => $name) {
546 // All and Unread stay visible at zero: "0 unread" is readable as
547 // good news rather than the tab vanishing, and All is the way
548 // back from every other view. Starred and Trash have to earn it.
549 $always_shown = ('published' === $status || 'unread' === $status);
550
551 if (!$always_shown && !$counts[$status]) {
552 continue;
553 }
554
555 $links[$status] = HashFormHelper::view_tab(
556 admin_url('admin.php?page=hashform-entries&status=' . $status), $name, $counts[$status], $status == $this->status
557 );
558 }
559
560 return $links;
561 }
562
563 public function views() {
564 HashFormHelper::render_view_tabs($this->get_views());
565 }
566
567 private function get_form_link($form_id) {
568 global $wpdb;
569
570 // One query for all form names instead of one per row.
571 if (null === $this->form_names) {
572 $this->form_names = array();
573 $forms = $wpdb->get_results("SELECT id, name FROM {$wpdb->prefix}hashform_forms", ARRAY_A);
574 foreach ($forms as $form) {
575 $this->form_names[$form['id']] = $form['name'];
576 }
577 }
578
579 $form_name = isset($this->form_names[$form_id]) ? $this->form_names[$form_id] : esc_html__('(deleted form)', 'hash-form');
580 return '<a href="' . esc_url(admin_url('admin.php?page=hashform&hashform_action=edit&id=' . $form_id)) . '">' . esc_html($form_name) . '</a>';
581 }
582
583 private function get_user_link($user_id) {
584 if ($user_id) {
585 $user_obj = get_user_by('id', $user_id);
586 if ($user_obj) {
587 return '<a data-id="' . esc_attr($user_id) . '" href="' . get_edit_user_link($user_id) . '">' . esc_html($user_obj->display_name) . '</a>';
588 }
589 }
590 return esc_html__('Guest', 'hash-form');
591 }
592
593 }
594