Entries. With no form_id it's a global overview (every form's * entry count, plus the most recent submissions across all forms). With a * form_id it drills into that form's own entries table. Plain PHP admin * tables — no JS bundle needed here, keeps this screen usable even before * the builder JS has loaded. */ class Entries_Page { public function __construct() { add_action( 'admin_menu', [ $this, 'register' ] ); add_action( 'admin_post_chargeforms_delete_entry', [ $this, 'handle_delete' ] ); add_action( 'admin_post_chargeforms_bulk_entry_action', [ $this, 'handle_bulk_entry_action' ] ); add_action( 'admin_post_chargeforms_export_entries', [ $this, 'handle_export' ] ); add_action( 'admin_post_chargeforms_toggle_entry_status', [ $this, 'handle_toggle_status' ] ); add_action( 'admin_post_chargeforms_print_entry', [ $this, 'handle_print' ] ); add_action( 'admin_post_chargeforms_import_entries', [ $this, 'handle_import' ] ); add_action( 'admin_post_chargeforms_update_entry', [ $this, 'handle_update_entry' ] ); add_action( 'admin_post_chargeforms_toggle_star', [ $this, 'handle_toggle_star' ] ); add_action( 'admin_post_chargeforms_add_entry_note', [ $this, 'handle_add_note' ] ); add_action( 'admin_post_chargeforms_resend_notification', [ $this, 'handle_resend_notification' ] ); } /** Admin screens (table columns, detail view, export) show a field's admin-only label when set, since it's meant to be clearer for staff than the public-facing label. */ /** Public (roadmap issue #31: "Admin Tables Mix Concerns in Large PHP Page Classes") so the extracted Entry_Exporter can reuse the exact same column-labeling this page's own table/detail/print views use, rather than a second copy of the same one-line rule. */ public static function column_label( array $field ) { return ! empty( $field['adminLabel'] ) ? $field['adminLabel'] : $field['label']; } /** * A stored field value is usually a plain string, but multi-value * fields (checkbox groups, Address's street/city/... sub-fields) are * stored as arrays — see SubmissionHandler::handle(), which only * scalar-sanitizes non-array values and array_maps the rest. Casting * an array straight to (string) produces the literal text "Array" * plus a PHP warning; on a host with display_errors on, that warning * prints inline mid-table and visibly wrecks the row's column * alignment. Every place a value reaches the page (table cell, detail * view, print view, CSV export) must go through this instead of a * raw (string) cast. */ /** Public — see column_label()'s identical note; Entry_Exporter's CSV/JSON export needs the same array-safe display rule every other view of an entry value uses. */ public static function display_value( $value ) { if ( ! is_array( $value ) ) { return (string) $value; } // A flat array (checkboxes/multi-select: every element already a // scalar) keeps its original ", "-joined behavior. A Repeater's // value is an array of ROWS, each itself an array of that row's // own sub-field values — array_map( 'strval', ... ) on THOSE rows // would trigger PHP's "Array to string conversion" and silently // render every row as the literal string "Array" instead of its // real values (found via live testing against a real submission, // not something a fixture built from already-flat sample data // would ever exercise). Detected once up front rather than // per-element so the common flat case pays zero extra cost. $is_nested = false; foreach ( $value as $item ) { if ( is_array( $item ) ) { $is_nested = true; break; } } if ( ! $is_nested ) { return implode( ', ', array_filter( array_map( 'strval', $value ), fn( $v ) => '' !== trim( $v ) ) ); } $rows = array_map( function ( $row ) { if ( ! is_array( $row ) ) { return (string) $row; } return implode( ', ', array_filter( array_map( 'strval', $row ), fn( $v ) => '' !== trim( $v ) ) ); }, $value ); return implode( '; ', array_filter( $rows, fn( $r ) => '' !== trim( $r ) ) ); } /** * Global Settings > Miscellaneous > "Date & Time Format" — relative * ("2 hours ago") or WordPress's own configured date/time format, * overriding the older free-form entries_date_format setting when set * to 'relative'. * * `$mysql_datetime` is stored in GMT (roadmap issue #27 — * Entry::create()/add_meta() both now store explicit GMT rather than * relying on the table's own timezone-ambiguous DEFAULT * CURRENT_TIMESTAMP). The relative branch compares against GMT "now" * to match; the absolute branches convert back to the site's * configured timezone via get_date_from_gmt() before formatting, so * an admin sees their OWN local time, not raw UTC. */ private static function format_entry_date( $mysql_datetime, $fallback_format ) { if ( 'relative' === Settings::get( 'date_time_format', 'relative' ) ) { /* translators: %s: human-readable time difference, e.g. "2 hours" */ return sprintf( __( '%s ago', 'chargeforms' ), human_time_diff( strtotime( $mysql_datetime ), current_time( 'timestamp', true ) ) ); } $local_datetime = get_date_from_gmt( $mysql_datetime, 'Y-m-d H:i:s' ); if ( 'wp_default' === Settings::get( 'date_time_format' ) ) { return mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $local_datetime ); } return mysql2date( $fallback_format, $local_datetime ); } /** * Turns a preset key (or an explicit custom range) into * [ date_from, date_to ] — both '' when no filter applies. * * `$today` must be GMT (roadmap issue #27), not site-local, since the * range this resolves into is compared against `created_at` via * gmdate()-built boundaries elsewhere (Entry::for_form()/query()) — * mixing a site-local "today" with GMT comparison boundaries silently * shifts which entries land in "today"/"this week"/etc. by the site's * UTC offset. */ private function resolve_date_preset( $preset, $custom_from, $custom_to ) { $today = current_time( 'Y-m-d', true ); switch ( $preset ) { case 'today': return [ $today, $today ]; case 'yesterday': $y = gmdate( 'Y-m-d', strtotime( $today . ' -1 day' ) ); return [ $y, $y ]; case 'last_week': return [ gmdate( 'Y-m-d', strtotime( $today . ' -7 days' ) ), $today ]; case 'last_month': return [ gmdate( 'Y-m-d', strtotime( $today . ' -30 days' ) ), $today ]; case 'custom': return [ $custom_from, $custom_to ]; default: return [ '', '' ]; } } public function register() { add_submenu_page( 'chargeforms', __( 'Entries', 'chargeforms' ), __( 'Entries', 'chargeforms' ) . self::unread_badge_html(), 'chargeforms_manage_forms', 'chargeforms-entries', [ $this, 'render' ] ); } /** The blue unread-count bubble next to "Entries" in the WP sidebar submenu and the ChargeForms admin-bar node — WP core menu titles intentionally allow HTML here (see its own Plugins-update bubble), so this isn't escaped. */ public static function unread_badge_html() { $count = Entry::count_unread(); if ( $count < 1 ) { return ''; } return ' ' . esc_html( $count > 99 ? '99+' : $count ) . ''; } public function render() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page load, not a state-changing request; both values are (int)-cast immediately. $form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; $entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $entry_id ) { $this->render_single( $entry_id ); return; } if ( ! $form_id ) { $this->render_overview(); return; } $form = Form::get( $form_id ); if ( ! $form ) { echo '

' . esc_html__( 'Form not found.', 'chargeforms' ) . '

'; return; } $this->render_form_entries( $form ); } /** Field types the Advanced Filter offers as a value
render_advanced_filter( $form_id, $filter_fields, $groups, $status ); ?>

$e['id'], $result['items'] ), 'starred' ); ?> >
render_sort_header( __( '#', 'chargeforms' ), 'id', $sort_by, $sort_dir ); ?> render_sort_header( __( 'Submitted at', 'chargeforms' ), 'created_at', $sort_by, $sort_dir ); ?>
render_simple_pagination_2( $result, $form_id, $groups, $status ); ?>
$filter_fields, 'operators' => [ [ 'value' => 'equal', 'label' => __( 'Equal', 'chargeforms' ) ], [ 'value' => 'not_equal', 'label' => __( 'Not Equal', 'chargeforms' ) ], [ 'value' => 'equal_in', 'label' => __( 'Equal In', 'chargeforms' ) ], [ 'value' => 'not_equal_in', 'label' => __( 'Not Equal In', 'chargeforms' ) ], [ 'value' => 'contains', 'label' => __( 'Contains', 'chargeforms' ) ], [ 'value' => 'not_contains', 'label' => __( 'Does Not Contain', 'chargeforms' ) ], [ 'value' => 'starts_with', 'label' => __( 'Starts With', 'chargeforms' ) ], [ 'value' => 'ends_with', 'label' => __( 'Ends With', 'chargeforms' ) ], [ 'value' => 'greater_than', 'label' => __( 'Greater Than', 'chargeforms' ) ], [ 'value' => 'less_than', 'label' => __( 'Less Than', 'chargeforms' ) ], [ 'value' => 'is_empty', 'label' => __( 'Is Empty', 'chargeforms' ) ], [ 'value' => 'is_not_empty', 'label' => __( 'Is Not Empty', 'chargeforms' ) ], ], 'groups' => $groups, 'formId' => $form_id, 'baseUrl' => admin_url( 'admin.php?page=chargeforms-entries&form_id=' . $form_id ), 'storageKey' => 'chargeforms_entries_columns_' . $form_id, ] ); ?> $key, 'sort_dir' => $next_dir, 'paged' => false ] ); ?>
>
', '', '', '' ); ?>

' . esc_html__( 'Entry not found.', 'chargeforms' ) . '

'; return; } $form = Form::get( $entry['form_id'] ); if ( ! $form ) { echo '

' . esc_html__( 'Form not found.', 'chargeforms' ) . '

'; return; } $date_format = Settings::get( 'entries_date_format', 'M j, Y g:ia' ); $table_fields = array_values( array_filter( $form['fields'], function ( $field ) { return ! in_array( $field['type'], self::NON_DATA_FIELD_TYPES, true ); } ) ); $siblings = Entry::for_form( $form['id'] ); $ids = array_map( fn( $e ) => (int) $e['id'], $siblings ); $pos = array_search( (int) $entry_id, $ids, true ); $prev_id = false !== $pos && $pos < count( $ids ) - 1 ? $ids[ $pos + 1 ] : null; $next_id = false !== $pos && $pos > 0 ? $ids[ $pos - 1 ] : null; $nav_url = fn( $id ) => admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $id . '&form_id=' . $form['id'] ); $view_url = admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $form['id'] ); $editing = isset( $_GET['edit'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $starred = '1' === (string) Entry::get_meta( $entry_id, 'starred' ); $source_url = Entry::get_meta( $entry_id, 'source_url' ); $browser = Entry::classify_browser( $entry['user_agent'] ); $device = $entry['user_agent'] ? Entry::classify_device( $entry['user_agent'] ) : ''; $submitter_id = (int) Entry::get_meta( $entry_id, 'submitted_by_user_id' ); $submitter = $submitter_id ? get_userdata( $submitter_id ) : false; $notes = Entry::get_meta_all( $entry_id, 'note' ); $logs = Entry::get_meta_all( $entry_id, 'activity_log' ); $status_options = [ 'unread' => __( 'Unread', 'chargeforms' ), 'read' => __( 'Read', 'chargeforms' ), ]; ?>

/ #

— #

‹ ‹ › ›

render_edit_input( $field, $entry['values'][ $field['id'] ] ?? '' ); ?>

  • ·

%s', esc_attr( $name ), esc_textarea( $value ) ); break; case 'select': printf( ''; break; case 'radio': foreach ( $field['options'] ?? [] as $option ) { printf( '', esc_attr( $name ), esc_attr( $option ), checked( $value, $option, false ), esc_html( $option ) ); } break; case 'checkbox': printf( '', esc_attr( $name ), checked( $value, '1', false ) ); break; case 'number': printf( '', esc_attr( $name ), esc_attr( $value ) ); break; case 'email': printf( '', esc_attr( $name ), esc_attr( $value ) ); break; case 'date': printf( '', esc_attr( $name ), esc_attr( $value ) ); break; case 'url': printf( '', esc_attr( $name ), esc_attr( $value ) ); break; default: printf( '', esc_attr( $name ), esc_attr( $value ) ); } } public function handle_update_entry() { $entry_id = isset( $_POST['entry_id'] ) ? (int) $_POST['entry_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_admin_referer( 'chargeforms_update_entry_' . $entry_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $entry = Entry::get( $entry_id ); if ( ! $entry ) { wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 ); } $form = Form::get( $entry['form_id'] ); $raw = isset( $_POST['chargeforms_data'] ) ? wp_unslash( $_POST['chargeforms_data'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput // Merge onto the existing values rather than replacing wholesale — the // edit form deliberately omits file fields (no re-upload support yet), // so a field absent from $_POST must keep its current stored value. $values = $entry['values']; foreach ( $form['fields'] ?? [] as $field ) { if ( ! array_key_exists( $field['id'], $raw ) ) { continue; } $val = $raw[ $field['id'] ]; $values[ $field['id'] ] = is_array( $val ) ? array_map( 'sanitize_text_field', $val ) : sanitize_textarea_field( $val ); } Entry::update( $entry_id, $values ); Entry::log_activity( $entry_id, __( 'Entry data edited', 'chargeforms' ) ); wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $entry['form_id'] . '&chargeforms_notice=updated' ) ); exit; } /** Toggles the "starred" flag (Entry::set_meta upserts, so repeated clicks don't accumulate rows). */ public function handle_toggle_star() { $entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_toggle_star_' . $entry_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $starred = '1' === (string) Entry::get_meta( $entry_id, 'starred' ); Entry::set_meta( $entry_id, 'starred', $starred ? '0' : '1' ); wp_safe_redirect( wp_get_referer() ?: admin_url( 'admin.php?page=chargeforms-entries' ) ); exit; } /** Adds one Submission Notes entry — stored the same way as the activity log, under its own meta_key so the two lists never mix. */ public function handle_add_note() { $entry_id = isset( $_POST['entry_id'] ) ? (int) $_POST['entry_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_admin_referer( 'chargeforms_add_entry_note_' . $entry_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $entry = Entry::get( $entry_id ); if ( ! $entry ) { wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 ); } $note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : ''; if ( '' !== $note ) { $user = wp_get_current_user(); Entry::add_meta( $entry_id, 'note', wp_json_encode( [ 'text' => $note, 'author' => $user->display_name, ] ) ); } wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $entry['form_id'] ) ); exit; } /** * Re-sends the form's owner-notification email for an existing entry — * reuses SubmissionHandler::send_entry_notification() so the resend uses * the exact same body-building logic as the original send, not a second * copy that could drift out of sync. Custom Recipient is validated with * is_email() before use — never trust an admin-post field as a mail * header input without checking it's actually a well-formed address. */ public function handle_resend_notification() { $entry_id = isset( $_POST['entry_id'] ) ? (int) $_POST['entry_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_admin_referer( 'chargeforms_resend_notification_' . $entry_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $entry = Entry::get( $entry_id ); if ( ! $entry ) { wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 ); } $form = Form::get( $entry['form_id'] ); if ( ! $form ) { wp_die( esc_html__( 'Form not found.', 'chargeforms' ), 404 ); } $recipient_mode = isset( $_POST['recipient_mode'] ) ? sanitize_key( wp_unslash( $_POST['recipient_mode'] ) ) : 'default'; $custom_email = isset( $_POST['custom_email'] ) ? sanitize_email( wp_unslash( $_POST['custom_email'] ) ) : ''; $to_override = ( 'custom' === $recipient_mode && is_email( $custom_email ) ) ? $custom_email : ''; SubmissionHandler::send_entry_notification( $form, $entry['values'], $entry_id, $to_override ); Entry::log_activity( $entry_id, $to_override /* translators: %s: recipient email address */ ? sprintf( __( 'Notification resent to %s', 'chargeforms' ), $to_override ) : __( 'Notification resent to default recipient', 'chargeforms' ) ); wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $entry['form_id'] . '&chargeforms_notice=notification_sent' ) ); exit; } private function render_overview() { $forms = Form::all(); if ( empty( $forms ) ) { ?>

resolve_date_preset( $date_preset, isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : '' // phpcs:ignore WordPress.Security.NonceVerification.Recommended ); $query_args = [ 'form_id' => isset( $_GET['filter_form_id'] ) ? (int) $_GET['filter_form_id'] : 0, // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'status' => isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : 'all', // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'search' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'page' => isset( $_GET['paged'] ) ? max( 1, (int) $_GET['paged'] ) : 1, // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'per_page' => isset( $_GET['per_page'] ) ? min( 200, max( 1, (int) $_GET['per_page'] ) ) : 10, // phpcs:ignore WordPress.Security.NonceVerification.Recommended 'date_from' => $date_from, 'date_to' => $date_to, ]; $result = Entry::query( $query_args ); $date_format = Settings::get( 'entries_date_format', 'M j, Y g:ia' ); $daily = Entry::counts_by_day( 14 ); $max_daily = max( 1, ...array_values( $daily ) ); $trashed_all = Entry::count_trashed(); ?>


$count ) : ?>

$e['id'], $result['items'] ), 'starred' ); ?>
'monitor', 'Mobile' => 'smartphone', 'Tablet' => 'tablet' ][ $device ] ?? 'globe'; $browser = $entry['user_agent'] ? Entry::classify_browser( $entry['user_agent'] ) : ''; ?>
render_entries_pagination( $result, $query_args ); ?>
'chargeforms-entries', 'filter_form_id' => $query_args['form_id'] ?: null, 'status' => 'all' === $query_args['status'] ? null : $query_args['status'], 's' => '' !== $query_args['search'] ? $query_args['search'] : null, 'per_page' => $result['per_page'], ] ); \ChargeForms\Includes\Pagination_Helper::render( $result, $base_args, [ 'jumper_id' => 'chargeforms-entries-jumper' ] ); } public function handle_toggle_status() { $entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0; $to = isset( $_GET['to'] ) ? sanitize_key( wp_unslash( $_GET['to'] ) ) : 'read'; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_toggle_status_' . $entry_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } // 'trashed' is a real, allowed transition here (Trash/Restore reuse // this same status-change machinery rather than a separate // handler), but it's never offered as a choice in the "Change // status to" dropdown (Admin\Entries_Page::render_single()'s // $status_options only lists read/unread) — Trash/Restore have // their own dedicated icon actions instead, matching how this // codebase already keeps the Favorite star toggle separate from // that same dropdown. $new_status = in_array( $to, [ 'read', 'unread', 'trashed' ], true ) ? $to : 'read'; Entry::set_status( $entry_id, $new_status ); $log_message = 'trashed' === $new_status ? __( 'Moved to Trash', 'chargeforms' ) /* translators: %s: new status (Read/Unread) */ : sprintf( __( 'Status changed to %s', 'chargeforms' ), ucfirst( $new_status ) ); Entry::log_activity( $entry_id, $log_message ); wp_safe_redirect( wp_get_referer() ?: admin_url( 'admin.php?page=chargeforms-entries' ) ); exit; } public function handle_delete() { $entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0; $form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_delete_entry_' . $entry_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } Entry::delete( $entry_id ); wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&form_id=' . $form_id . '&chargeforms_notice=entry_deleted' ) ); exit; } /** * The row-checkbox column's actual action (roadmap gap analysis: checkboxes * previously led nowhere) — applies one action to every checked entry in a * single request rather than one-at-a-time. Started as delete-only; * expanded to match the reference product's own bulk-actions bar (mark read/unread, * favorite/unfavorite, delete) since those reuse the exact same * single-entry logic (Entry::set_status()/set_meta()) this page's row * actions already call — just looped, not new functionality invented for * the bulk case. */ public function handle_bulk_entry_action() { $form_id = isset( $_POST['form_id'] ) ? (int) $_POST['form_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'chargeforms_bulk_entry_action_' . $form_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $entry_ids = isset( $_POST['entry_ids'] ) ? array_map( 'intval', (array) $_POST['entry_ids'] ) : []; $bulk_action = isset( $_POST['bulk_action'] ) ? sanitize_key( wp_unslash( $_POST['bulk_action'] ) ) : ''; $entry_ids = array_values( array_filter( $entry_ids, fn( $id ) => $id > 0 ) ); $notice = ''; switch ( $bulk_action ) { case 'delete': // Permanent — only ever offered as a bulk option from within // the Trashed view (see the toolbar's contextual bulk_action // options below); 'trash' below is the soft-delete every // other view's "Delete Selected" now means. foreach ( $entry_ids as $entry_id ) { Entry::delete( $entry_id ); } $notice = 'entries_deleted'; break; case 'trash': case 'restore': $status = 'trash' === $bulk_action ? 'trashed' : 'read'; foreach ( $entry_ids as $entry_id ) { Entry::set_status( $entry_id, $status ); Entry::log_activity( $entry_id, 'trash' === $bulk_action ? __( 'Moved to Trash', 'chargeforms' ) : __( 'Restored from Trash', 'chargeforms' ) ); } $notice = 'trash' === $bulk_action ? 'entries_trashed' : 'entries_restored'; break; case 'mark_read': case 'mark_unread': $status = 'mark_read' === $bulk_action ? 'read' : 'unread'; foreach ( $entry_ids as $entry_id ) { Entry::set_status( $entry_id, $status ); /* translators: %s: new status (Read/Unread) */ Entry::log_activity( $entry_id, sprintf( __( 'Status changed to %s', 'chargeforms' ), ucfirst( $status ) ) ); } $notice = 'entries_updated'; break; case 'mark_favorite': case 'unmark_favorite': $flag = 'mark_favorite' === $bulk_action ? '1' : '0'; foreach ( $entry_ids as $entry_id ) { Entry::set_meta( $entry_id, 'starred', $flag ); } $notice = 'entries_updated'; break; default: // Empty/unrecognized bulk_action — the Apply button is a no-op // client-side too (admin.js) until something is selected; a // direct/malformed POST just redirects back with no changes, // same as picking nothing and clicking Apply in the UI. break; } $redirect = admin_url( 'admin.php?page=chargeforms-entries&form_id=' . $form_id ); // Restoring/deleting-permanently out of the Trashed view lands back // on the normal view (the acted-on rows are no longer trashed, so // staying on ?status=trashed would just show them vanish); trashing // from the normal view has nowhere else to go either. The one case // worth preserving is staying on ?status=trashed after a delete, so // a site owner permanently-deleting several trashed rows one batch // at a time doesn't get bounced out after each batch. if ( 'delete' === $bulk_action && isset( $_POST['status'] ) ) { $redirect = add_query_arg( 'status', sanitize_key( wp_unslash( $_POST['status'] ) ), $redirect ); } if ( $notice ) { $redirect = add_query_arg( 'chargeforms_notice', $notice, $redirect ); } wp_safe_redirect( $redirect ); exit; } /** Standalone print-friendly view of one entry — opened in a new tab, calls window.print() on load. */ public function handle_print() { $entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_print_entry_' . $entry_id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $entry = Entry::get( $entry_id ); if ( ! $entry ) { wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 ); } $form = Form::get( $entry['form_id'] ); $date_format = Settings::get( 'entries_date_format', 'M j, Y g:ia' ); // Dedicated no-src handles so this admin-post standalone print page // (outside the normal wp_enqueue_scripts/wp_enqueue_styles lifecycle) // can carry its own inline CSS/JS through wp_add_inline_style()/ // wp_add_inline_script() instead of raw